Deploy updated nsedash (clean obfuscated deployment)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +0 -11
- app.py +1 -1
- nsecorpact.py +0 -284
- nsedash/__init__.py +0 -0
- nsedash/config.py +0 -221
- nsedash/core/__init__.py +0 -0
- nsedash/core/dashboard_service.py +0 -887
- nsedash/core/hf_sync.py +0 -338
- nsedash/core/live_fetch.py +0 -361
- nsedash/core/nvidia_llm.py +0 -933
- nsedash/core/nvidia_model_registry.json +0 -981
- nsedash/core/portfolio.py +0 -311
- nsedash/core/recommender_bridge.py +0 -110
- nsedash/core/stock_data.py +0 -504
- nsedash/core/token_state.json +0 -37
- nsedash/core/user_auth.py +0 -835
- nsedash/core/userdata_db.py +0 -206
- nsedash/core/watchlists.py +0 -262
- nsedash/crew/__init__.py +0 -0
- nsedash/crew/agents.py +0 -221
- nsedash/crew/crews.py +0 -128
- nsedash/crew/framework.py +0 -151
- nsedash/logging_utils.py +0 -82
- nsedash/web/__init__.py +0 -112
- nsedash/web/api.py +0 -1196
- nsedash/web/background.py +0 -281
- nsedash/web/routes.py +0 -14
- nsedash/web/static/app.js +0 -1548
- nsedash/web/static/style.css +0 -924
- nsedash/web/templates/index.html +0 -489
- nsedash_admin.py +0 -479
- nsedash_api.py +0 -653
- nsedash_app.py +0 -212
- nsedash_app.zip +2 -2
- nsedash_start.py +1 -1
- nsedown.py +0 -479
- nsemaxlive.py +0 -553
- nsemaxmin.py +0 -147
- nsemcupdate.py +0 -418
- nseprocessor.py +0 -1258
- nsereco/__init__.py +0 -22
- nsereco/agent_workflow.py +0 -229
- nsereco/data.py +0 -749
- nsereco/models.py +0 -274
- nsereco/recommender.py +0 -0
- nsereco/routes.py +0 -260
- nsereco/static/css/style.css +0 -377
- nsereco/static/js/app.js +0 -261
- nsereco/task_manager.py +0 -96
- nsereco/templates/index.html +0 -134
README.md
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Nsedash
|
| 3 |
-
emoji: 🌖
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: yellow
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
short_description: NSE Symbols information in a Dashboard
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -44,7 +44,7 @@ def _silent_loader():
|
|
| 44 |
if not app_script.exists():
|
| 45 |
app_script = Path("nsedash_app.py")
|
| 46 |
|
| 47 |
-
args = [sys.executable, str(app_script), "-hf"]
|
| 48 |
try:
|
| 49 |
os.execv(sys.executable, args)
|
| 50 |
except Exception:
|
|
|
|
| 44 |
if not app_script.exists():
|
| 45 |
app_script = Path("nsedash_app.py")
|
| 46 |
|
| 47 |
+
args = [sys.executable, str(app_script), "--hf"]
|
| 48 |
try:
|
| 49 |
os.execv(sys.executable, args)
|
| 50 |
except Exception:
|
nsecorpact.py
DELETED
|
@@ -1,284 +0,0 @@
|
|
| 1 |
-
# This script processes corporate action data from the NSE and generates a JSON file
|
| 2 |
-
# containing the necessary information for dashboard integration.
|
| 3 |
-
|
| 4 |
-
import pandas as pd
|
| 5 |
-
import re
|
| 6 |
-
import requests
|
| 7 |
-
import os
|
| 8 |
-
from datetime import datetime, timedelta
|
| 9 |
-
|
| 10 |
-
# --- Configuration and Setup ---
|
| 11 |
-
# Directory to save processed files (final destination)
|
| 12 |
-
processed_dir = './processed'
|
| 13 |
-
# Temporary directory for downloads
|
| 14 |
-
temp_dir = './tempfiles'
|
| 15 |
-
|
| 16 |
-
# Ensure both directories exist
|
| 17 |
-
os.makedirs(processed_dir, exist_ok=True)
|
| 18 |
-
os.makedirs(temp_dir, exist_ok=True)
|
| 19 |
-
|
| 20 |
-
# Output JSON file path
|
| 21 |
-
output_file = os.path.join(processed_dir, 'nse_dashboard_corpact.json')
|
| 22 |
-
|
| 23 |
-
# Base URL for downloading corporate actions data
|
| 24 |
-
DOWNLOAD_URL_TEMPLATE = "https://www.nseindia.com/api/corporates-corporateActions?index=equities&from_date=01-01-2020&to_date={}&csv=true"
|
| 25 |
-
|
| 26 |
-
# URL for the main corporate actions page (to get session cookies)
|
| 27 |
-
NSE_CORPORATE_ACTIONS_PAGE = "https://www.nseindia.com/companies-listing/corporate-filings-actions"
|
| 28 |
-
|
| 29 |
-
# --- Helper Functions for Date and Download ---
|
| 30 |
-
|
| 31 |
-
def get_last_nse_working_day():
|
| 32 |
-
"""
|
| 33 |
-
Determines the last NSE working day (i.e., the most recent weekday).
|
| 34 |
-
NSE is typically closed on Saturdays and Sundays.
|
| 35 |
-
"""
|
| 36 |
-
today = datetime.now()
|
| 37 |
-
# Subtract days until a weekday is found (Monday=0, Tuesday=1, ..., Sunday=6)
|
| 38 |
-
# If today is Saturday (5), subtract 1 to get Friday.
|
| 39 |
-
# If today is Sunday (6), subtract 2 to get Friday.
|
| 40 |
-
# If today is Monday-Friday, use today.
|
| 41 |
-
while today.weekday() > 4: # 5 is Saturday, 6 is Sunday
|
| 42 |
-
today -= timedelta(days=1)
|
| 43 |
-
return today.strftime('%d-%m-%Y')
|
| 44 |
-
|
| 45 |
-
def download_nse_corporate_actions_file():
|
| 46 |
-
"""
|
| 47 |
-
Downloads the corporate actions CSV file from NSE to the tempfiles directory.
|
| 48 |
-
It returns the path to the downloaded file in the temporary directory.
|
| 49 |
-
Returns None if download fails.
|
| 50 |
-
"""
|
| 51 |
-
last_working_day = get_last_nse_working_day()
|
| 52 |
-
download_url = DOWNLOAD_URL_TEMPLATE.format(last_working_day)
|
| 53 |
-
|
| 54 |
-
# Construct the temporary file name and path
|
| 55 |
-
temp_file_name = f"CF-CA-equities-01-01-2020-to-{last_working_day}.csv"
|
| 56 |
-
temp_file_path = os.path.join(temp_dir, temp_file_name)
|
| 57 |
-
|
| 58 |
-
# Note: We always attempt to download the latest to temp, no skipping here.
|
| 59 |
-
# The check for existing file is done at the processed_dir level later.
|
| 60 |
-
|
| 61 |
-
print(f"Attempting to download corporate actions data to temporary directory.")
|
| 62 |
-
|
| 63 |
-
# Create a session to persist cookies and headers
|
| 64 |
-
session = requests.Session()
|
| 65 |
-
|
| 66 |
-
try:
|
| 67 |
-
# Step 1: Visit the main corporate actions page to get necessary cookies
|
| 68 |
-
print(f"Fetching cookies.")
|
| 69 |
-
session_headers = {
|
| 70 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
| 71 |
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
| 72 |
-
'Accept-Language': 'en-US,en;q=0.9',
|
| 73 |
-
'Connection': 'keep-alive',
|
| 74 |
-
'Upgrade-Insecure-Requests': '1',
|
| 75 |
-
}
|
| 76 |
-
session.get(NSE_CORPORATE_ACTIONS_PAGE, headers=session_headers)
|
| 77 |
-
|
| 78 |
-
# Step 2: Use the same session to download the CSV file
|
| 79 |
-
print(f"Downloading CSV using established session.")
|
| 80 |
-
download_headers = {
|
| 81 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
| 82 |
-
'Accept': 'text/csv', # Specifically request CSV
|
| 83 |
-
'Accept-Language': 'en-US,en;q=0.9',
|
| 84 |
-
'Connection': 'keep-alive',
|
| 85 |
-
'Referer': NSE_CORPORATE_ACTIONS_PAGE, # Refer to the main page visited earlier
|
| 86 |
-
'Sec-Fetch-Dest': 'document',
|
| 87 |
-
'Sec-Fetch-Mode': 'navigate',
|
| 88 |
-
'Sec-Fetch-Site': 'same-origin',
|
| 89 |
-
'Sec-Fetch-User': '?1',
|
| 90 |
-
'Upgrade-Insecure-Requests': '1',
|
| 91 |
-
}
|
| 92 |
-
response = session.get(download_url, headers=download_headers)
|
| 93 |
-
response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
|
| 94 |
-
|
| 95 |
-
# Get the raw binary content
|
| 96 |
-
raw_bytes_content = response.content
|
| 97 |
-
|
| 98 |
-
# --- BOM Preprocessing ---
|
| 99 |
-
# Detect and remove UTF-8 BOM if present (EF BB BF)
|
| 100 |
-
if raw_bytes_content.startswith(b'\xef\xbb\xbf'):
|
| 101 |
-
# print("Detected and removed UTF-8 BOM from the file content.")
|
| 102 |
-
raw_bytes_content = raw_bytes_content[3:]
|
| 103 |
-
# --- End BOM Preprocessing ---
|
| 104 |
-
|
| 105 |
-
# Decode to string for further preprocessing (only line stripping, no .strip('"'))
|
| 106 |
-
raw_content_string = raw_bytes_content.decode('latin1', errors='ignore')
|
| 107 |
-
|
| 108 |
-
# Preprocessing: Only strip leading/trailing whitespace from each line
|
| 109 |
-
cleaned_lines = [line.strip() for line in raw_content_string.splitlines()]
|
| 110 |
-
cleaned_content = "\n".join(cleaned_lines)
|
| 111 |
-
|
| 112 |
-
# Save the cleaned content to the temporary file path
|
| 113 |
-
with open(temp_file_path, 'w', encoding='latin1') as f:
|
| 114 |
-
f.write(cleaned_content)
|
| 115 |
-
|
| 116 |
-
print(f"Successfully downloaded and preprocessed to temporary file.")
|
| 117 |
-
return temp_file_path
|
| 118 |
-
except requests.exceptions.RequestException as e:
|
| 119 |
-
print(f"Error downloading file: {e}")
|
| 120 |
-
print("This often indicates an issue with the NSE API (e.g., authentication required, rate limiting, or changed endpoint).")
|
| 121 |
-
print("You may still need to manually download the file if this persists.")
|
| 122 |
-
return None
|
| 123 |
-
|
| 124 |
-
# --- Existing Functions (Copied from original prompt) ---
|
| 125 |
-
|
| 126 |
-
# Function to calculate adjustment factor for bonuses
|
| 127 |
-
def calculate_bonus_adj_factor(purpose):
|
| 128 |
-
"""Extracts and calculates the adjustment factor for bonus shares."""
|
| 129 |
-
if "Bonus" in purpose:
|
| 130 |
-
try:
|
| 131 |
-
# Ratio format example: "1:4" in "Bonus 1:4"
|
| 132 |
-
# It's safer to use regex to extract the ratio explicitly
|
| 133 |
-
match = re.search(r"Bonus (\d+:\d+)", purpose)
|
| 134 |
-
if match:
|
| 135 |
-
ratio = match.group(1)
|
| 136 |
-
bonus, base = map(int, ratio.split(":"))
|
| 137 |
-
return f"{bonus}:{base}", "Bonus"
|
| 138 |
-
except ValueError:
|
| 139 |
-
print(f"Could not parse bonus ratio from: {purpose}")
|
| 140 |
-
return None, None
|
| 141 |
-
|
| 142 |
-
# Function to calculate adjustment factor for stock splits
|
| 143 |
-
def calculate_split_adj_factor(purpose):
|
| 144 |
-
"""Extracts and calculates the adjustment factor for stock splits."""
|
| 145 |
-
if "Face Value Split" in purpose:
|
| 146 |
-
try:
|
| 147 |
-
# Use regex to find the 'From' and 'To' values
|
| 148 |
-
# Example: "Face Value Split (Sub-Division) - From Rs 10/- Per Share To Re 1/- Per Share"
|
| 149 |
-
match = re.search(r"From Rs (\d+(?:\.\d+)?)/?-? Per Share To (?:Re|Rs) (\d+(?:\.\d+)?)/?-? Per Share", purpose)
|
| 150 |
-
if match:
|
| 151 |
-
from_value_str = match.group(1)
|
| 152 |
-
to_value_str = match.group(2)
|
| 153 |
-
|
| 154 |
-
# Convert to float to handle potential decimal values
|
| 155 |
-
from_value = float(from_value_str)
|
| 156 |
-
to_value = float(to_value_str)
|
| 157 |
-
|
| 158 |
-
# Return ratio as 'new_face_value:old_face_value'
|
| 159 |
-
# For a split from 10 to 1, the ratio is 1:10
|
| 160 |
-
return f"{int(to_value)}:{int(from_value)}", "Split"
|
| 161 |
-
else:
|
| 162 |
-
pass # No match found
|
| 163 |
-
except ValueError:
|
| 164 |
-
print(f"Could not parse split values from: {purpose}")
|
| 165 |
-
return None, None
|
| 166 |
-
|
| 167 |
-
def main():
|
| 168 |
-
temp_downloaded_file_path = None # Path of the file in tempfiles
|
| 169 |
-
input_file = None # Final path of the file in processed_dir
|
| 170 |
-
df = None # DataFrame for processing
|
| 171 |
-
|
| 172 |
-
# Step 1: Attempt to download and preprocess the input file to tempfiles
|
| 173 |
-
temp_downloaded_file_path = download_nse_corporate_actions_file()
|
| 174 |
-
|
| 175 |
-
# Define the final target file path in the processed directory
|
| 176 |
-
final_processed_file_name = 'Corporate_action.parquet'
|
| 177 |
-
final_processed_file_path = os.path.join(processed_dir, final_processed_file_name)
|
| 178 |
-
|
| 179 |
-
if temp_downloaded_file_path and os.path.exists(temp_downloaded_file_path):
|
| 180 |
-
try:
|
| 181 |
-
# Read downloaded CSV
|
| 182 |
-
df_temp = pd.read_csv(temp_downloaded_file_path, encoding='latin1')
|
| 183 |
-
# Save to Parquet
|
| 184 |
-
df_temp.to_parquet(final_processed_file_path, index=False)
|
| 185 |
-
# Remove temp CSV
|
| 186 |
-
os.remove(temp_downloaded_file_path)
|
| 187 |
-
# Also clean up any legacy Corporate_action.csv in the processed dir
|
| 188 |
-
legacy_csv = os.path.join(processed_dir, 'Corporate_action.csv')
|
| 189 |
-
if os.path.exists(legacy_csv):
|
| 190 |
-
os.remove(legacy_csv)
|
| 191 |
-
input_file = final_processed_file_path
|
| 192 |
-
print("CSV downloaded file converted to Parquet and saved successfully.")
|
| 193 |
-
except Exception as e:
|
| 194 |
-
print(f"Error processing downloaded CSV file: {e}")
|
| 195 |
-
input_file = final_processed_file_path if os.path.exists(final_processed_file_path) else None
|
| 196 |
-
else:
|
| 197 |
-
print("\nAutomatic download to temporary directory failed or file not found in tempfiles.")
|
| 198 |
-
# Fallback: Check for the specifically named file in processed_dir
|
| 199 |
-
print(f"Checking for '{final_processed_file_name}' in '{processed_dir}' as a fallback...")
|
| 200 |
-
if os.path.exists(final_processed_file_path):
|
| 201 |
-
input_file = final_processed_file_path
|
| 202 |
-
print(f"Using existing file as input: '{input_file}'")
|
| 203 |
-
else:
|
| 204 |
-
# Check if there is an existing Corporate_action.csv to convert
|
| 205 |
-
fallback_csv = os.path.join(processed_dir, 'Corporate_action.csv')
|
| 206 |
-
if os.path.exists(fallback_csv):
|
| 207 |
-
try:
|
| 208 |
-
df_temp = pd.read_csv(fallback_csv, encoding='latin1')
|
| 209 |
-
df_temp.to_parquet(final_processed_file_path, index=False)
|
| 210 |
-
os.remove(fallback_csv)
|
| 211 |
-
input_file = final_processed_file_path
|
| 212 |
-
print(f"Auto-converted existing '{fallback_csv}' to Parquet.")
|
| 213 |
-
except Exception as e:
|
| 214 |
-
print(f"Failed to auto-convert existing CSV fallback: {e}")
|
| 215 |
-
|
| 216 |
-
if not input_file:
|
| 217 |
-
print(f"No '{final_processed_file_name}' found in '{processed_dir}'. Cannot proceed without an input file.")
|
| 218 |
-
print(f"Please manually download the latest CSV from the following URL and place it in the '{temp_dir}' directory, then rerun the script:")
|
| 219 |
-
print(f" {DOWNLOAD_URL_TEMPLATE.format(get_last_nse_working_day())}")
|
| 220 |
-
return # Exit if no input file is available
|
| 221 |
-
|
| 222 |
-
# Step 4: Use the determined input file as the dataframe source
|
| 223 |
-
if input_file and os.path.exists(input_file): # Ensure input_file exists before reading
|
| 224 |
-
try:
|
| 225 |
-
df = pd.read_parquet(input_file)
|
| 226 |
-
print(f"Successfully loaded data from '{input_file}'.")
|
| 227 |
-
except Exception as e:
|
| 228 |
-
print(f"Error reading input Parquet file '{input_file}': {e}")
|
| 229 |
-
return # Exit if reading the Parquet fails
|
| 230 |
-
else:
|
| 231 |
-
print("No valid input file determined or file does not exist. Exiting without processing.")
|
| 232 |
-
return
|
| 233 |
-
|
| 234 |
-
# Initialize an output DataFrame
|
| 235 |
-
output_data = []
|
| 236 |
-
|
| 237 |
-
# Only proceed if df was successfully loaded
|
| 238 |
-
if df is not None:
|
| 239 |
-
for _, row in df.iterrows():
|
| 240 |
-
symbol = row['SYMBOL']
|
| 241 |
-
purpose = str(row['PURPOSE']) # Ensure purpose is a string
|
| 242 |
-
ex_date = row['EX-DATE']
|
| 243 |
-
|
| 244 |
-
ratio, corp_action = None, None
|
| 245 |
-
|
| 246 |
-
# Calculate adjustment factor and corp action type
|
| 247 |
-
if "Bonus" in purpose:
|
| 248 |
-
ratio, corp_action = calculate_bonus_adj_factor(purpose)
|
| 249 |
-
elif "Face Value Split" in purpose:
|
| 250 |
-
ratio, corp_action = calculate_split_adj_factor(purpose)
|
| 251 |
-
|
| 252 |
-
# Only add data if a valid corporate action and ratio were identified
|
| 253 |
-
if ratio and corp_action:
|
| 254 |
-
try:
|
| 255 |
-
# Convert date to YYYY-MM-DD format, handling potential errors
|
| 256 |
-
formatted_date = pd.to_datetime(ex_date, format='%d-%b-%Y').strftime('%Y-%m-%d')
|
| 257 |
-
output_data.append({
|
| 258 |
-
"symbol": symbol,
|
| 259 |
-
"type": corp_action,
|
| 260 |
-
"date": formatted_date,
|
| 261 |
-
"ratio": ratio
|
| 262 |
-
})
|
| 263 |
-
except ValueError as ve:
|
| 264 |
-
print(f"Error parsing date '{ex_date}' for symbol '{symbol}': {ve}")
|
| 265 |
-
except Exception as e:
|
| 266 |
-
print(f"An unexpected error occurred processing row for symbol '{symbol}': {e}")
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
# Convert output data to a DataFrame
|
| 270 |
-
output_df = pd.DataFrame(output_data)
|
| 271 |
-
|
| 272 |
-
# Save to the target file in JSON format
|
| 273 |
-
output_df.to_json(output_file, orient='records', indent=4)
|
| 274 |
-
|
| 275 |
-
print(f"\nCorporate action data processed and saved to {output_file}")
|
| 276 |
-
print(f"Total records processed: {len(df)}")
|
| 277 |
-
print(f"Total valid corporate actions extracted: {len(output_df)}")
|
| 278 |
-
else:
|
| 279 |
-
print("Dataframe 'df' was not created due to previous errors. No processing performed.")
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
if __name__ == "__main__":
|
| 283 |
-
main()
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/__init__.py
DELETED
|
File without changes
|
nsedash/config.py
DELETED
|
@@ -1,221 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.config
|
| 3 |
-
==============
|
| 4 |
-
Central configuration: directory layout, file paths, constants, index
|
| 5 |
-
constituent lists, and the Recommendation enum.
|
| 6 |
-
|
| 7 |
-
All paths derive from a single base directory (default: current working
|
| 8 |
-
directory) and preserve the exact directory contract of nsedash.py v4.5:
|
| 9 |
-
|
| 10 |
-
<base_dir>/
|
| 11 |
-
processed/
|
| 12 |
-
nse_processed_data.csv
|
| 13 |
-
nse_summary_data.parquet
|
| 14 |
-
nse_dashboard_etflist.json
|
| 15 |
-
nse_dashboard_mclink.json
|
| 16 |
-
nse_dashboard_contents.json
|
| 17 |
-
nse_dashboard_config.json
|
| 18 |
-
recommender/
|
| 19 |
-
watchlists/
|
| 20 |
-
PortFolioEqtSummary.csv (optional)
|
| 21 |
-
SecurityMaster.zip (optional)
|
| 22 |
-
downloads/
|
| 23 |
-
nse_dashboard.log
|
| 24 |
-
nse_dashboard_metrics.log
|
| 25 |
-
"""
|
| 26 |
-
|
| 27 |
-
from __future__ import annotations
|
| 28 |
-
|
| 29 |
-
import os
|
| 30 |
-
from enum import Enum
|
| 31 |
-
from pathlib import Path
|
| 32 |
-
|
| 33 |
-
# ---------------------------------------------------------------------------
|
| 34 |
-
# Runtime mode flags (set by CLI switches or HF Space environment)
|
| 35 |
-
# ---------------------------------------------------------------------------
|
| 36 |
-
|
| 37 |
-
#: True when running inside a Hugging Face Space (SPACE_ID is set) or
|
| 38 |
-
#: when the ``-hf`` CLI flag was passed.
|
| 39 |
-
HF_MODE = bool(int(os.environ.get("HF_MODE", "0")))
|
| 40 |
-
|
| 41 |
-
#: False when the ``-noadmin`` CLI flag was passed. Disables admin-only
|
| 42 |
-
#: actions (script runner, raw data tools, etc.).
|
| 43 |
-
ADMIN_MODE = bool(int(os.environ.get("ADMIN_MODE", "1")))
|
| 44 |
-
|
| 45 |
-
#: HuggingFace dataset repo used for two-way data persistence.
|
| 46 |
-
HF_DATASET_REPO = os.environ.get("NSEDASH_HF_DATASET", "Sam-max1/nsedash_data")
|
| 47 |
-
|
| 48 |
-
# ---------------------------------------------------------------------------
|
| 49 |
-
# Constants
|
| 50 |
-
# ---------------------------------------------------------------------------
|
| 51 |
-
|
| 52 |
-
DATA_RETENTION_DAYS = 730 # 2-year historical data window
|
| 53 |
-
DEFAULT_REFRESH_RATE_MIN = 5 # live data polling interval (minutes)
|
| 54 |
-
DEFAULT_VOLUME_CUTOFF = 10_000 # minimum average volume for volume analysis
|
| 55 |
-
WATCHLIST_SLOTS = 20 # named watchlist slots
|
| 56 |
-
GRID_COLUMNS = 5 # dashboard grid columns (UI hint)
|
| 57 |
-
AUTO_QTY_BUDGET = 100_000 # ₹ budget for the auto-quantity heuristic
|
| 58 |
-
ANALYSIS_LIMIT = 50 # top-N rows for every analysis screen
|
| 59 |
-
HTTP_TIMEOUT_SEC = 10 # network timeout (AUDIT FIX C-1: original had none)
|
| 60 |
-
FETCH_MAX_WORKERS = 8 # concurrent symbol fetches (AUDIT FIX P-1)
|
| 61 |
-
|
| 62 |
-
MARKET_TZ = "Asia/Kolkata" # NSE market timezone (AUDIT FIX H-8)
|
| 63 |
-
MARKET_OPEN = (9, 15)
|
| 64 |
-
MARKET_CLOSE = (15, 30)
|
| 65 |
-
|
| 66 |
-
REQUEST_HEADERS = {
|
| 67 |
-
'User-Agent': (
|
| 68 |
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
| 69 |
-
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
| 70 |
-
'Chrome/125.0.0.0 Safari/537.36'
|
| 71 |
-
),
|
| 72 |
-
'Accept-Language': 'en-US,en;q=0.9',
|
| 73 |
-
'Accept-Encoding': 'gzip, deflate, br',
|
| 74 |
-
'Accept': (
|
| 75 |
-
'text/html,application/xhtml+xml,application/xml;'
|
| 76 |
-
'q=0.9,image/webp,image/apng,*/*;q=0.8,'
|
| 77 |
-
'application/signed-exchange;v=b3;q=0.7'
|
| 78 |
-
),
|
| 79 |
-
'Connection': 'keep-alive',
|
| 80 |
-
'Upgrade-Insecure-Requests': '1',
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
CSV_COLUMNS = [
|
| 84 |
-
'SYMBOL', 'SECURITY', 'TRAN_DATE',
|
| 85 |
-
'OPEN_PRICE', 'HIGH_PRICE', 'LOW_PRICE',
|
| 86 |
-
'PREV_CL_PR', 'CLOSE_PRICE', 'NET_TRDVAL',
|
| 87 |
-
'NET_TRDQTY', 'TRADES', 'HI_52_WK', 'LO_52_WK',
|
| 88 |
-
'HI_DIFF', 'LO_DIFF'
|
| 89 |
-
]
|
| 90 |
-
|
| 91 |
-
CSV_DTYPES = {
|
| 92 |
-
'SYMBOL': 'category',
|
| 93 |
-
'SECURITY': 'category',
|
| 94 |
-
'OPEN_PRICE': 'float32',
|
| 95 |
-
'HIGH_PRICE': 'float32',
|
| 96 |
-
'LOW_PRICE': 'float32',
|
| 97 |
-
'PREV_CL_PR': 'float32',
|
| 98 |
-
'CLOSE_PRICE': 'float32',
|
| 99 |
-
'NET_TRDVAL': 'float32',
|
| 100 |
-
'NET_TRDQTY': 'float32',
|
| 101 |
-
'TRADES': 'float32',
|
| 102 |
-
'HI_52_WK': 'float32',
|
| 103 |
-
'LO_52_WK': 'float32',
|
| 104 |
-
'HI_DIFF': 'float32',
|
| 105 |
-
'LO_DIFF': 'float32',
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
class Recommendation(str, Enum):
|
| 110 |
-
"""LSTM model recommendation values."""
|
| 111 |
-
BUY = "BUY"
|
| 112 |
-
SELL = "SELL"
|
| 113 |
-
HOLD = "HOLD"
|
| 114 |
-
NA = "N/A"
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
# ---------------------------------------------------------------------------
|
| 118 |
-
# Index Constituent Lists (retained verbatim from v4.5)
|
| 119 |
-
# ---------------------------------------------------------------------------
|
| 120 |
-
|
| 121 |
-
SENSEX_STOCKS = [
|
| 122 |
-
"ASIANPAINT", "AXISBANK", "BAJAJFINSV", "BAJFINANCE", "BHARTIARTL",
|
| 123 |
-
"BPCL", "DRREDDY", "HDFC", "HDFCBANK", "HEROMOTOCO",
|
| 124 |
-
"HINDUNILVR", "HCLTECH", "ICICIBANK", "INDUSINDBK", "INFY",
|
| 125 |
-
"ITC", "KOTAKBANK", "LT", "M&M", "MARUTI",
|
| 126 |
-
"NESTLEIND", "NTPC", "ONGC", "POWERGRID", "RELIANCE",
|
| 127 |
-
"SBIN", "SUNPHARMA", "TCS", "TATAMOTORS", "ULTRACEMCO"
|
| 128 |
-
]
|
| 129 |
-
|
| 130 |
-
NIFTY_STOCKS = [
|
| 131 |
-
"ADANIENT", "ADANIPORTS", "APOLLOHOSP", "ASIANPAINT", "AXISBANK",
|
| 132 |
-
"BAJAJ-AUTO", "BAJFINANCE", "BAJAJFINSV", "BEL", "BHARTIARTL",
|
| 133 |
-
"BPCL", "BRITANNIA", "CIPLA", "COALINDIA", "DRREDDY",
|
| 134 |
-
"EICHERMOT", "GRASIM", "HCLTECH", "HDFCLIFE", "HDFCBANK",
|
| 135 |
-
"HEROMOTOCO", "HINDALCO", "HINDUNILVR", "ICICIBANK", "INDUSINDBK",
|
| 136 |
-
"INFY", "ITC", "JIOFIN", "JSWSTEEL", "KOTAKBANK",
|
| 137 |
-
"LT", "M&M", "MARUTI", "NESTLEIND", "NTPC",
|
| 138 |
-
"ONGC", "POWERGRID", "RELIANCE", "SBIN", "SBILIFE",
|
| 139 |
-
"SHRIRAMFIN", "SUNPHARMA", "TATACONSUM", "TATAMOTORS", "TATASTEEL",
|
| 140 |
-
"TCS", "TECHM", "TITAN", "TRENT", "ULTRACEMCO", "WIPRO"
|
| 141 |
-
]
|
| 142 |
-
|
| 143 |
-
UTILITY_SCRIPTS = [
|
| 144 |
-
("Download Daily Data", "nsedown.py"),
|
| 145 |
-
("Process Corporate Actions", "nsecorpact.py"),
|
| 146 |
-
("Process Daily Data", "nseprocessor.py"),
|
| 147 |
-
("Process Max / Min", "nsesymhist.py"),
|
| 148 |
-
("Download Live Data", "nsemaxlive.py"),
|
| 149 |
-
]
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
class Paths:
|
| 154 |
-
"""All filesystem paths for a given base directory."""
|
| 155 |
-
|
| 156 |
-
def __init__(self, base_dir: str | os.PathLike | None = None, session_token: str | None = None):
|
| 157 |
-
self.base_dir = Path(base_dir or os.environ.get("NSEDASH_BASE_DIR") or Path.cwd())
|
| 158 |
-
self.session_token = session_token
|
| 159 |
-
self.data_dir = self.base_dir / 'processed'
|
| 160 |
-
|
| 161 |
-
# Isolate session files
|
| 162 |
-
if session_token and session_token != "admin":
|
| 163 |
-
self.watchlist_dir = self.data_dir / 'watchlists' / f"session_{session_token}"
|
| 164 |
-
self.state_file = self.data_dir / f"nse_dashboard_contents_{session_token}.json"
|
| 165 |
-
self.config_file = self.data_dir / f"nse_dashboard_config_{session_token}.json"
|
| 166 |
-
self.portfolio_file = self.data_dir / f"PortFolioEqtSummary_{session_token}.parquet"
|
| 167 |
-
else:
|
| 168 |
-
self.watchlist_dir = self.data_dir / 'watchlists'
|
| 169 |
-
self.state_file = self.data_dir / 'nse_dashboard_contents.json'
|
| 170 |
-
self.config_file = self.data_dir / 'nse_dashboard_config.json'
|
| 171 |
-
self.portfolio_file = self.data_dir / 'PortFolioEqtSummary.parquet'
|
| 172 |
-
|
| 173 |
-
self.log_dir = self.base_dir / 'downloads'
|
| 174 |
-
self.recommender_dir = self.data_dir / 'recommender'
|
| 175 |
-
self.aianalysis_dir = self.data_dir / 'aianalysis'
|
| 176 |
-
|
| 177 |
-
self.input_file = self.data_dir / 'nse_processed_data.parquet'
|
| 178 |
-
self.extract_file = self.data_dir / 'nse_summary_data.parquet'
|
| 179 |
-
self.etf_list_file = self.data_dir / 'nse_dashboard_etflist.json'
|
| 180 |
-
self.mc_link_file = self.data_dir / 'nse_dashboard_mclink.json'
|
| 181 |
-
self.log_file = self.log_dir / 'nse_dashboard.log'
|
| 182 |
-
self.metrics_file = self.log_dir / 'nse_dashboard_metrics.log'
|
| 183 |
-
self.security_master_zip = self.data_dir / 'SecurityMaster.zip'
|
| 184 |
-
|
| 185 |
-
wl = self.watchlist_dir
|
| 186 |
-
# Live analysis watchlists
|
| 187 |
-
self.max_vol_watchlist_file = wl / 'nse_dashboard_maxvol50.json'
|
| 188 |
-
self.max_price_watchlist_file = wl / 'nse_dashboard_maxprice50.json'
|
| 189 |
-
self.max_fall_watchlist_file = wl / 'nse_dashboard_maxfall50.json'
|
| 190 |
-
self.max_score_watchlist_file = wl / 'nse_dashboard_maxscore50.json'
|
| 191 |
-
self.max_return_files = {
|
| 192 |
-
"1W": wl / 'nse_dashboard_maxreturn1w50.json',
|
| 193 |
-
"1M": wl / 'nse_dashboard_maxreturn1m50.json',
|
| 194 |
-
"3M": wl / 'nse_dashboard_maxreturn3m50.json',
|
| 195 |
-
}
|
| 196 |
-
# Historical max/min price analysis watchlists
|
| 197 |
-
self.hist_price_files = {
|
| 198 |
-
("1D", "Max"): wl / 'nse_dashboard_hist_maxprice1d50.json',
|
| 199 |
-
("1D", "Min"): wl / 'nse_dashboard_hist_minprice1d50.json',
|
| 200 |
-
("1W", "Max"): wl / 'nse_dashboard_hist_maxprice1w50.json',
|
| 201 |
-
("1W", "Min"): wl / 'nse_dashboard_hist_minprice1w50.json',
|
| 202 |
-
("1M", "Max"): wl / 'nse_dashboard_hist_maxprice1m50.json',
|
| 203 |
-
("1M", "Min"): wl / 'nse_dashboard_hist_minprice1m50.json',
|
| 204 |
-
("3M", "Max"): wl / 'nse_dashboard_hist_maxprice3m50.json',
|
| 205 |
-
("3M", "Min"): wl / 'nse_dashboard_hist_minprice3m50.json',
|
| 206 |
-
}
|
| 207 |
-
# Historical volume analysis watchlists
|
| 208 |
-
self.hist_vol_files = {
|
| 209 |
-
"1D": wl / 'nse_dashboard_hist_maxvol1d50.json',
|
| 210 |
-
"1W": wl / 'nse_dashboard_hist_maxvol1w50.json',
|
| 211 |
-
"1M": wl / 'nse_dashboard_hist_maxvol1m50.json',
|
| 212 |
-
"3M": wl / 'nse_dashboard_hist_maxvol3m50.json',
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
def watchlist_slot(self, n: int) -> Path:
|
| 216 |
-
"""Path for numbered watchlist slot n (1..WATCHLIST_SLOTS)."""
|
| 217 |
-
return self.watchlist_dir / f"nse_dashboard_watchlist{n}.json"
|
| 218 |
-
|
| 219 |
-
def ensure_dirs(self) -> None:
|
| 220 |
-
for d in (self.data_dir, self.data_dir / 'watchlists', self.watchlist_dir, self.log_dir, self.recommender_dir, self.aianalysis_dir):
|
| 221 |
-
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/__init__.py
DELETED
|
File without changes
|
nsedash/core/dashboard_service.py
DELETED
|
@@ -1,887 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.dashboard_service
|
| 3 |
-
==============================
|
| 4 |
-
Server-side state controller — the headless successor to v4.5's
|
| 5 |
-
``StockDashboard``. All Tkinter dialogs become structured return payloads
|
| 6 |
-
(``{"ok":..., "message":..., ...}``) the frontend renders as toasts/modals;
|
| 7 |
-
all card widgets become JSON card payloads rendered by the browser.
|
| 8 |
-
|
| 9 |
-
State parity with v4.5:
|
| 10 |
-
cells (ordered stock cards with Quantity/Price/current_cmp/selection),
|
| 11 |
-
stocks universe keyed "SYMBOL - SECURITY", selected_date,
|
| 12 |
-
current_watchlist_name, watchlist_modified, real_time_active,
|
| 13 |
-
live_data_source(+preference), live_refresh_rate,
|
| 14 |
-
volume_cutoff_threshold, selected_symbols_for_watchlist.
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
from __future__ import annotations
|
| 18 |
-
|
| 19 |
-
import logging
|
| 20 |
-
import threading
|
| 21 |
-
from datetime import datetime, timedelta
|
| 22 |
-
from pathlib import Path
|
| 23 |
-
from zoneinfo import ZoneInfo
|
| 24 |
-
|
| 25 |
-
import pandas as pd
|
| 26 |
-
|
| 27 |
-
from ..config import (AUTO_QTY_BUDGET, DEFAULT_REFRESH_RATE_MIN,
|
| 28 |
-
DEFAULT_VOLUME_CUTOFF, GRID_COLUMNS, MARKET_CLOSE,
|
| 29 |
-
MARKET_OPEN, MARKET_TZ, NIFTY_STOCKS, SENSEX_STOCKS,
|
| 30 |
-
WATCHLIST_SLOTS, Paths)
|
| 31 |
-
from .portfolio import compute_portfolio_summary
|
| 32 |
-
from .stock_data import StockData
|
| 33 |
-
from .watchlists import WatchlistStore
|
| 34 |
-
|
| 35 |
-
CHART_WINDOW = 90 # points displayed, parity with v4.5 update_graph
|
| 36 |
-
DMA_WINDOWS = (9, 20, 50, 200)
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class Cell:
|
| 40 |
-
"""One stock card's server-side state."""
|
| 41 |
-
|
| 42 |
-
__slots__ = ("symbol", "quantity", "price", "current_cmp", "prev_close",
|
| 43 |
-
"selected", "live", "recommendation")
|
| 44 |
-
|
| 45 |
-
def __init__(self, symbol: str, quantity: float = 0.0, price: float = 0.0):
|
| 46 |
-
self.symbol = symbol
|
| 47 |
-
self.quantity = float(quantity)
|
| 48 |
-
self.price = float(price)
|
| 49 |
-
self.current_cmp = 0.0
|
| 50 |
-
self.prev_close = 0.0
|
| 51 |
-
self.selected = False
|
| 52 |
-
self.live: dict | None = None
|
| 53 |
-
self.recommendation: dict | None = None
|
| 54 |
-
|
| 55 |
-
def get_data(self) -> dict:
|
| 56 |
-
"""Parity with StockCell.get_data()."""
|
| 57 |
-
return {"SYMBOL": self.symbol, "Quantity": self.quantity,
|
| 58 |
-
"Price": self.price}
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
class DashboardService:
|
| 62 |
-
|
| 63 |
-
def __init__(self, paths: Paths, hf_sync=None):
|
| 64 |
-
self.paths = paths
|
| 65 |
-
self._lock = threading.RLock()
|
| 66 |
-
self.stock_data = StockData(paths)
|
| 67 |
-
self.watchlists = WatchlistStore(paths, hf_sync=hf_sync)
|
| 68 |
-
|
| 69 |
-
self.cells: list[Cell] = []
|
| 70 |
-
self.stocks: dict[str, pd.Series] = {}
|
| 71 |
-
self.COLUMNS = GRID_COLUMNS
|
| 72 |
-
self.real_time_active = False
|
| 73 |
-
self.last_refresh = None
|
| 74 |
-
self.live_data_source = "N/A"
|
| 75 |
-
self.live_data_source_preference: str | None = None
|
| 76 |
-
self.live_refresh_rate = DEFAULT_REFRESH_RATE_MIN
|
| 77 |
-
self.watchlist_modified = False
|
| 78 |
-
self.volume_cutoff_threshold = DEFAULT_VOLUME_CUTOFF
|
| 79 |
-
self.selected_date = self.stock_data.last_date
|
| 80 |
-
self.watchlist_names = self.watchlists.load_watchlist_names()
|
| 81 |
-
|
| 82 |
-
cfg = self.watchlists.load_config()
|
| 83 |
-
self.current_watchlist_name = cfg.get(
|
| 84 |
-
'current_watchlist_name', "Default List"
|
| 85 |
-
)
|
| 86 |
-
self._mc_links_cache: dict | None = None
|
| 87 |
-
self.load_universe()
|
| 88 |
-
self.load_dashboard_state_by_name(self.current_watchlist_name)
|
| 89 |
-
|
| 90 |
-
# ==================================================================
|
| 91 |
-
# Universe / date data
|
| 92 |
-
# ==================================================================
|
| 93 |
-
|
| 94 |
-
def load_universe(self) -> None:
|
| 95 |
-
"""Build the stocks dict for the selected date (v4.5 load_data)."""
|
| 96 |
-
data = self.stock_data.get_data_for_date(self.selected_date)
|
| 97 |
-
with self._lock:
|
| 98 |
-
self.stocks = {
|
| 99 |
-
f"{row['SYMBOL']} - {row['SECURITY']}": row
|
| 100 |
-
for _, row in data.iterrows()
|
| 101 |
-
}
|
| 102 |
-
# AUDIT FIX P-8: symbol → key index removes the O(cells×stocks)
|
| 103 |
-
# linear prefix scans v4.5 performed throughout.
|
| 104 |
-
self._symbol_key_index = {
|
| 105 |
-
str(row['SYMBOL']): key for key, row in self.stocks.items()
|
| 106 |
-
}
|
| 107 |
-
self._mc_links_cache = None
|
| 108 |
-
|
| 109 |
-
def key_for_symbol(self, symbol: str) -> str | None:
|
| 110 |
-
return self._symbol_key_index.get(symbol)
|
| 111 |
-
|
| 112 |
-
def mc_links(self) -> dict:
|
| 113 |
-
if self._mc_links_cache is None:
|
| 114 |
-
self._mc_links_cache = self.stock_data.load_mc_link_data()
|
| 115 |
-
return self._mc_links_cache
|
| 116 |
-
|
| 117 |
-
# ==================================================================
|
| 118 |
-
# Card payloads
|
| 119 |
-
# ==================================================================
|
| 120 |
-
|
| 121 |
-
@staticmethod
|
| 122 |
-
def _chart_payload(price_history, volume_history) -> dict | None:
|
| 123 |
-
"""
|
| 124 |
-
90-day chart series with 9/20/50/200 DMA computed over the full
|
| 125 |
-
history (parity with StockCell.update_graph). Sent to the browser
|
| 126 |
-
for client-side rendering.
|
| 127 |
-
"""
|
| 128 |
-
if not price_history or len(price_history) < 2:
|
| 129 |
-
return None
|
| 130 |
-
dates = [x[0] for x in price_history]
|
| 131 |
-
prices = [float(x[1]) for x in price_history]
|
| 132 |
-
volumes = [float(x[1]) for x in (volume_history or [])]
|
| 133 |
-
prices_series = pd.Series(prices)
|
| 134 |
-
start = max(0, len(dates) - CHART_WINDOW)
|
| 135 |
-
|
| 136 |
-
def fmt_date(d):
|
| 137 |
-
if hasattr(d, 'strftime'):
|
| 138 |
-
return d.strftime('%Y-%m-%d')
|
| 139 |
-
return str(d)[:10]
|
| 140 |
-
|
| 141 |
-
payload = {
|
| 142 |
-
"dates": [fmt_date(d) for d in dates[start:]],
|
| 143 |
-
"prices": prices[start:],
|
| 144 |
-
"volumes": volumes[start:] if volumes else [],
|
| 145 |
-
}
|
| 146 |
-
for w in DMA_WINDOWS:
|
| 147 |
-
if len(prices_series) >= w:
|
| 148 |
-
dma = prices_series.rolling(window=w).mean()
|
| 149 |
-
payload[f"dma_{w}"] = [
|
| 150 |
-
None if pd.isna(v) else round(float(v), 2)
|
| 151 |
-
for v in dma.iloc[start:]
|
| 152 |
-
]
|
| 153 |
-
else:
|
| 154 |
-
payload[f"dma_{w}"] = None
|
| 155 |
-
return payload
|
| 156 |
-
|
| 157 |
-
def _cell_payload(self, cell: Cell, include_chart: bool = True) -> dict:
|
| 158 |
-
"""Full card payload (historic + live + MC + P&L inputs)."""
|
| 159 |
-
key = self.key_for_symbol(cell.symbol)
|
| 160 |
-
row = self.stocks.get(key) if key else None
|
| 161 |
-
mc = self.mc_links().get(cell.symbol, {})
|
| 162 |
-
payload = {
|
| 163 |
-
"symbol": cell.symbol,
|
| 164 |
-
"quantity": cell.quantity,
|
| 165 |
-
"price": cell.price,
|
| 166 |
-
"current_cmp": cell.current_cmp,
|
| 167 |
-
"selected": cell.selected,
|
| 168 |
-
"live": cell.live,
|
| 169 |
-
"recommendation": cell.recommendation,
|
| 170 |
-
"mc": {
|
| 171 |
-
"mcap": mc.get('MC_MCAP', 0.0),
|
| 172 |
-
"fv": mc.get('MC_FV', 0.0),
|
| 173 |
-
"score": mc.get('MC_SCORE', 0.0),
|
| 174 |
-
"ath": mc.get('MC_ATH', 0.0),
|
| 175 |
-
"atl": mc.get('MC_ATL', 0.0),
|
| 176 |
-
"link": mc.get('MC_LINK'),
|
| 177 |
-
},
|
| 178 |
-
}
|
| 179 |
-
if row is not None:
|
| 180 |
-
prev_price = float(row['PREV_CL_PR'])
|
| 181 |
-
curr_price = float(row['CLOSE_PRICE'])
|
| 182 |
-
try:
|
| 183 |
-
daily_change = (
|
| 184 |
-
(curr_price / prev_price - 1) * 100 if prev_price != 0 else 0.0
|
| 185 |
-
)
|
| 186 |
-
except (ValueError, TypeError):
|
| 187 |
-
daily_change = 0.0
|
| 188 |
-
vc = row.get('VOLUME_CHANGES') or []
|
| 189 |
-
padded = ([0.0] * (5 - len(vc)) + list(vc)) if len(vc) < 5 else list(vc)[:5]
|
| 190 |
-
payload.update({
|
| 191 |
-
"security": str(row['SECURITY']),
|
| 192 |
-
"tran_date": row['TRAN_DATE'].strftime('%d %b %Y'),
|
| 193 |
-
"prev_close": prev_price,
|
| 194 |
-
"close_price": curr_price,
|
| 195 |
-
"hi_52wk": float(row['HI_52_WK']),
|
| 196 |
-
"hi_diff": float(row['HI_DIFF']),
|
| 197 |
-
"lo_52wk": float(row['LO_52_WK']),
|
| 198 |
-
"lo_diff": float(row['LO_DIFF']),
|
| 199 |
-
"changes": {
|
| 200 |
-
"1D": daily_change,
|
| 201 |
-
"1W": float(row.get('WEEK_CHANGE', 0) or 0),
|
| 202 |
-
"1M": float(row.get('MONTH_CHANGE', 0) or 0),
|
| 203 |
-
"3M": float(row.get('THREE_MONTH_CHANGE', 0) or 0),
|
| 204 |
-
},
|
| 205 |
-
# Display order parity: reversed padded list, labelled D1..D5
|
| 206 |
-
"volume_changes": padded[::-1],
|
| 207 |
-
})
|
| 208 |
-
if include_chart:
|
| 209 |
-
payload["chart"] = self._chart_payload(
|
| 210 |
-
row.get('PRICE_HISTORY'), row.get('VOLUME_HISTORY')
|
| 211 |
-
)
|
| 212 |
-
return payload
|
| 213 |
-
|
| 214 |
-
def board_payload(self, include_charts: bool = True) -> dict:
|
| 215 |
-
"""Complete dashboard snapshot for the frontend."""
|
| 216 |
-
with self._lock:
|
| 217 |
-
return {
|
| 218 |
-
"banner": {
|
| 219 |
-
"mode": "live" if self.real_time_active else "historic",
|
| 220 |
-
"source": self.live_data_source,
|
| 221 |
-
"watchlist": self.current_watchlist_name,
|
| 222 |
-
"refresh_rate": self.live_refresh_rate,
|
| 223 |
-
},
|
| 224 |
-
"selected_date": str(self.selected_date)[:10],
|
| 225 |
-
"last_data_date": str(self.stock_data.last_date)[:10],
|
| 226 |
-
"watchlist_modified": self.watchlist_modified,
|
| 227 |
-
"watchlist_names": self.watchlist_names,
|
| 228 |
-
"volume_cutoff": self.volume_cutoff_threshold,
|
| 229 |
-
"columns": self.COLUMNS,
|
| 230 |
-
"cells": [
|
| 231 |
-
self._cell_payload(c, include_charts) for c in self.cells
|
| 232 |
-
],
|
| 233 |
-
"portfolio": self.portfolio_summary(),
|
| 234 |
-
"search_keys": sorted(self.stocks.keys()),
|
| 235 |
-
"trading_hour": self.is_trading_hour(),
|
| 236 |
-
"indices": self.get_index_quotes(),
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
# ==================================================================
|
| 240 |
-
# Cell management (add/remove/delete-all/inputs/selection)
|
| 241 |
-
# ==================================================================
|
| 242 |
-
|
| 243 |
-
def add_stock(self, selected: str | None = None, quantity: float = 0.0,
|
| 244 |
-
price: float = 0.0, save_state: bool = True) -> Cell | None:
|
| 245 |
-
with self._lock:
|
| 246 |
-
if not selected or selected not in self.stocks:
|
| 247 |
-
logging.warning(
|
| 248 |
-
f"Skipping addition: stock '{selected}' not found in data."
|
| 249 |
-
)
|
| 250 |
-
return None
|
| 251 |
-
row = self.stocks[selected]
|
| 252 |
-
symbol = str(row['SYMBOL'])
|
| 253 |
-
# Auto-quantity heuristic (v4.5 M-8): only on addition.
|
| 254 |
-
if float(quantity) == 0.0 and float(price) == 0.0:
|
| 255 |
-
curr_price = float(row.get('CLOSE_PRICE', 0) or 0)
|
| 256 |
-
if curr_price > 0:
|
| 257 |
-
price = curr_price
|
| 258 |
-
quantity = int(AUTO_QTY_BUDGET / curr_price)
|
| 259 |
-
cell = Cell(symbol, quantity, price)
|
| 260 |
-
cell.current_cmp = float(row.get('CLOSE_PRICE', 0) or 0)
|
| 261 |
-
cell.prev_close = float(row.get('PREV_CL_PR', 0) or 0)
|
| 262 |
-
self.cells.append(cell)
|
| 263 |
-
if save_state:
|
| 264 |
-
self.save_dashboard_state()
|
| 265 |
-
return cell
|
| 266 |
-
|
| 267 |
-
def _find_cell(self, symbol: str) -> Cell | None:
|
| 268 |
-
return next((c for c in self.cells if c.symbol == symbol), None)
|
| 269 |
-
|
| 270 |
-
def remove_stock(self, symbol: str) -> bool:
|
| 271 |
-
with self._lock:
|
| 272 |
-
cell = self._find_cell(symbol)
|
| 273 |
-
if not cell:
|
| 274 |
-
return False
|
| 275 |
-
self.cells.remove(cell)
|
| 276 |
-
self.save_dashboard_state()
|
| 277 |
-
return True
|
| 278 |
-
|
| 279 |
-
def delete_all_stocks(self, mark_modified: bool = True) -> None:
|
| 280 |
-
with self._lock:
|
| 281 |
-
self.cells.clear()
|
| 282 |
-
if mark_modified:
|
| 283 |
-
self.watchlist_modified = True
|
| 284 |
-
self.save_dashboard_state()
|
| 285 |
-
|
| 286 |
-
def update_cell_inputs(self, symbol: str, quantity: float,
|
| 287 |
-
price: float) -> dict:
|
| 288 |
-
"""Quantity/Price entry change (v4.5 _on_entry_change)."""
|
| 289 |
-
with self._lock:
|
| 290 |
-
cell = self._find_cell(symbol)
|
| 291 |
-
if not cell:
|
| 292 |
-
return {"ok": False,
|
| 293 |
-
"message": f"Stock {symbol} not on the board."}
|
| 294 |
-
try:
|
| 295 |
-
cell.quantity = float(quantity)
|
| 296 |
-
cell.price = float(price)
|
| 297 |
-
except (TypeError, ValueError):
|
| 298 |
-
cell.quantity = 0.0
|
| 299 |
-
cell.price = 0.0
|
| 300 |
-
return {"ok": False,
|
| 301 |
-
"message": "Please enter valid numbers for "
|
| 302 |
-
"Quantity and Price."}
|
| 303 |
-
self.watchlist_modified = True
|
| 304 |
-
return {"ok": True, "portfolio": self.portfolio_summary()}
|
| 305 |
-
|
| 306 |
-
def toggle_selection(self, symbol: str, selected: bool) -> None:
|
| 307 |
-
with self._lock:
|
| 308 |
-
cell = self._find_cell(symbol)
|
| 309 |
-
if cell:
|
| 310 |
-
cell.selected = bool(selected)
|
| 311 |
-
|
| 312 |
-
@property
|
| 313 |
-
def selected_symbols_for_watchlist(self) -> list[str]:
|
| 314 |
-
return [c.symbol for c in self.cells if c.selected]
|
| 315 |
-
|
| 316 |
-
# ==================================================================
|
| 317 |
-
# Live data
|
| 318 |
-
# ==================================================================
|
| 319 |
-
|
| 320 |
-
def is_trading_hour(self) -> bool:
|
| 321 |
-
"""True within NSE market hours (IST). AUDIT FIX H-8: v4.5 used the
|
| 322 |
-
server's local clock; this uses Asia/Kolkata explicitly."""
|
| 323 |
-
now = datetime.now(ZoneInfo(MARKET_TZ))
|
| 324 |
-
if not (0 <= now.weekday() <= 4):
|
| 325 |
-
return False
|
| 326 |
-
market_open = now.replace(hour=MARKET_OPEN[0], minute=MARKET_OPEN[1],
|
| 327 |
-
second=0, microsecond=0)
|
| 328 |
-
market_close = now.replace(hour=MARKET_CLOSE[0], minute=MARKET_CLOSE[1],
|
| 329 |
-
second=0, microsecond=0)
|
| 330 |
-
return market_open <= now <= market_close
|
| 331 |
-
|
| 332 |
-
def apply_live_quotes(self, quotes: list[dict], source: str) -> int:
|
| 333 |
-
"""Apply fetched quotes to matching cells; returns update count."""
|
| 334 |
-
updated = 0
|
| 335 |
-
with self._lock:
|
| 336 |
-
self.live_data_source = source
|
| 337 |
-
by_symbol = {q["SYMBOL"]: q for q in quotes}
|
| 338 |
-
for cell in self.cells:
|
| 339 |
-
q = by_symbol.get(cell.symbol)
|
| 340 |
-
if not q:
|
| 341 |
-
continue
|
| 342 |
-
curr = float(q['CLOSE_PRICE'])
|
| 343 |
-
prev = float(q['PREV_CL_PR'])
|
| 344 |
-
curr_vol = float(q.get('CURR_VOL', 0) or 0)
|
| 345 |
-
prev_vol = float(q.get('PREV_VOL', 0) or 0)
|
| 346 |
-
cell.current_cmp = curr
|
| 347 |
-
cell.live = {
|
| 348 |
-
"price": curr,
|
| 349 |
-
"pct_change": (curr / prev - 1) * 100 if prev else 0,
|
| 350 |
-
"day_high": float(q.get('DAY_HIGH', 0) or 0),
|
| 351 |
-
"day_low": float(q.get('DAY_LOW', 0) or 0),
|
| 352 |
-
"vol_change": (
|
| 353 |
-
(curr_vol / prev_vol - 1) * 100 if prev_vol else 0.0
|
| 354 |
-
),
|
| 355 |
-
"updated_at": datetime.now(ZoneInfo(MARKET_TZ)).strftime('%d %b %Y | %H:%M:%S IST'),
|
| 356 |
-
}
|
| 357 |
-
updated += 1
|
| 358 |
-
self.last_refresh = datetime.now(ZoneInfo(MARKET_TZ))
|
| 359 |
-
return updated
|
| 360 |
-
|
| 361 |
-
def get_index_quotes(self) -> dict[str, dict]:
|
| 362 |
-
"""Fetch live index quotes for NIFTY and SENSEX with 15s cache."""
|
| 363 |
-
import time
|
| 364 |
-
now = time.time()
|
| 365 |
-
if hasattr(self, '_index_cache') and self._index_cache and (now - getattr(self, '_index_cache_time', 0) < 15):
|
| 366 |
-
return self._index_cache
|
| 367 |
-
try:
|
| 368 |
-
fetcher = LiveFetcher(session=self.session)
|
| 369 |
-
quotes = fetcher.fetch_index_quotes()
|
| 370 |
-
if quotes:
|
| 371 |
-
self._index_cache = quotes
|
| 372 |
-
self._index_cache_time = now
|
| 373 |
-
return quotes
|
| 374 |
-
except Exception as e:
|
| 375 |
-
logging.warning(f"Error fetching index quotes: {e}")
|
| 376 |
-
return getattr(self, '_index_cache', {})
|
| 377 |
-
|
| 378 |
-
def load_live_data(self) -> dict:
|
| 379 |
-
"""One live refresh cycle for all displayed stocks, with post-market caching."""
|
| 380 |
-
symbols = [c.symbol for c in self.cells]
|
| 381 |
-
if not symbols:
|
| 382 |
-
return {"ok": True, "updated": 0, "source": self.live_data_source}
|
| 383 |
-
|
| 384 |
-
now = datetime.now(ZoneInfo(MARKET_TZ))
|
| 385 |
-
cache_file = self.paths.data_dir / 'nse_live_quotes_cache.json'
|
| 386 |
-
is_live = self.is_trading_hour()
|
| 387 |
-
|
| 388 |
-
# If market is closed, check cache first
|
| 389 |
-
if not is_live:
|
| 390 |
-
# Helper to calculate last session date
|
| 391 |
-
def get_last_session_date(dt: datetime) -> datetime.date:
|
| 392 |
-
curr = dt
|
| 393 |
-
if curr.weekday() < 5:
|
| 394 |
-
if curr.hour < 15 or (curr.hour == 15 and curr.minute < 30):
|
| 395 |
-
curr -= timedelta(days=1)
|
| 396 |
-
while curr.weekday() >= 5:
|
| 397 |
-
curr -= timedelta(days=1)
|
| 398 |
-
return curr.date()
|
| 399 |
-
|
| 400 |
-
last_session_now = get_last_session_date(now)
|
| 401 |
-
|
| 402 |
-
if cache_file.exists():
|
| 403 |
-
try:
|
| 404 |
-
import json
|
| 405 |
-
with open(cache_file, 'r') as f:
|
| 406 |
-
cache_data = json.load(f)
|
| 407 |
-
cache_time = datetime.fromisoformat(cache_data["timestamp"]).astimezone(ZoneInfo(MARKET_TZ))
|
| 408 |
-
last_session_cache = get_last_session_date(cache_time)
|
| 409 |
-
|
| 410 |
-
# Cache is valid if it's from the same last trading session and was pulled at/after 15:30
|
| 411 |
-
is_same_session = last_session_now == last_session_cache
|
| 412 |
-
is_post_close = cache_time.hour > 15 or (cache_time.hour == 15 and cache_time.minute >= 30)
|
| 413 |
-
|
| 414 |
-
if is_same_session and is_post_close:
|
| 415 |
-
logging.info("Loading live data from post-market cache.")
|
| 416 |
-
quotes = cache_data["quotes"]
|
| 417 |
-
# Filter quotes to only keep those currently on the board
|
| 418 |
-
board_symbols = set(symbols)
|
| 419 |
-
filtered_quotes = [q for q in quotes if q["SYMBOL"] in board_symbols]
|
| 420 |
-
updated = self.apply_live_quotes(filtered_quotes, cache_data["source"])
|
| 421 |
-
return {
|
| 422 |
-
"ok": True,
|
| 423 |
-
"updated": updated,
|
| 424 |
-
"source": cache_data["source"],
|
| 425 |
-
"cached": True,
|
| 426 |
-
"portfolio": self.portfolio_summary(),
|
| 427 |
-
"message": "Loaded live data from post-market cache."
|
| 428 |
-
}
|
| 429 |
-
except Exception as e:
|
| 430 |
-
logging.warning(f"Failed to read live quote cache: {e}")
|
| 431 |
-
|
| 432 |
-
logging.info("No valid post-market cache found. Pulling live data once after market hours.")
|
| 433 |
-
|
| 434 |
-
# If market is open, or cache is missing/invalid when closed:
|
| 435 |
-
sess_id = self.paths.state_file.stem.split('_')[-1] if '_' in self.paths.state_file.stem else 'admin'
|
| 436 |
-
logging.info("[LIVE] Starting live data pull cycle — %d symbol(s) (Source: %s, Session: %s)",
|
| 437 |
-
len(symbols), self.live_data_source_preference or 'Google', sess_id)
|
| 438 |
-
quotes, source = self.stock_data.fetch_real_time_data(
|
| 439 |
-
symbols, preferred_source=self.live_data_source_preference or "Google"
|
| 440 |
-
)
|
| 441 |
-
if not quotes:
|
| 442 |
-
logging.error("Failed to fetch live data from any source.")
|
| 443 |
-
return {"ok": False, "updated": 0, "source": "None",
|
| 444 |
-
"message": "Failed to fetch live data from any source."}
|
| 445 |
-
|
| 446 |
-
# Save to cache
|
| 447 |
-
try:
|
| 448 |
-
import json
|
| 449 |
-
cache_payload = {
|
| 450 |
-
"timestamp": now.isoformat(),
|
| 451 |
-
"source": source,
|
| 452 |
-
"quotes": quotes
|
| 453 |
-
}
|
| 454 |
-
with open(cache_file, 'w') as f:
|
| 455 |
-
json.dump(cache_payload, f, indent=4)
|
| 456 |
-
logging.info("Successfully updated live quote cache.")
|
| 457 |
-
except Exception as e:
|
| 458 |
-
logging.warning(f"Failed to write live quote cache: {e}")
|
| 459 |
-
|
| 460 |
-
updated = self.apply_live_quotes(quotes, source)
|
| 461 |
-
return {"ok": True, "updated": updated, "source": source,
|
| 462 |
-
"portfolio": self.portfolio_summary()}
|
| 463 |
-
|
| 464 |
-
def start_live(self, source: str, refresh_rate: int | None) -> dict:
|
| 465 |
-
"""Enable live mode (v4.5 toggle_live_data + ask_live_data_source)."""
|
| 466 |
-
with self._lock:
|
| 467 |
-
if source not in ("Google", "Yahoo"):
|
| 468 |
-
self.real_time_active = False
|
| 469 |
-
return {"ok": False,
|
| 470 |
-
"message": "Live data not started as source "
|
| 471 |
-
"selection was cancelled."}
|
| 472 |
-
try:
|
| 473 |
-
rate = int(refresh_rate) if refresh_rate else DEFAULT_REFRESH_RATE_MIN
|
| 474 |
-
except (TypeError, ValueError):
|
| 475 |
-
rate = DEFAULT_REFRESH_RATE_MIN
|
| 476 |
-
self.live_refresh_rate = rate if rate > 0 else DEFAULT_REFRESH_RATE_MIN
|
| 477 |
-
self.live_data_source_preference = source
|
| 478 |
-
self.real_time_active = True
|
| 479 |
-
self.last_refresh = datetime.now()
|
| 480 |
-
sess_id = self.paths.state_file.stem.split('_')[-1] if '_' in self.paths.state_file.stem else 'admin'
|
| 481 |
-
print(f"[LIVE] Activating live data pull: source={source}, rate={self.live_refresh_rate}m (Session: {sess_id})", flush=True)
|
| 482 |
-
return {"ok": True, "refresh_rate": self.live_refresh_rate}
|
| 483 |
-
|
| 484 |
-
def stop_live(self) -> dict:
|
| 485 |
-
"""Disable live mode and restore historic data (v4.5 behaviour)."""
|
| 486 |
-
with self._lock:
|
| 487 |
-
self.real_time_active = False
|
| 488 |
-
self.last_refresh = None
|
| 489 |
-
for cell in self.cells:
|
| 490 |
-
cell.live = None
|
| 491 |
-
logging.info("Live data updates stopped. Displaying historic data.")
|
| 492 |
-
self.load_historic_data()
|
| 493 |
-
return {"ok": True}
|
| 494 |
-
|
| 495 |
-
def load_historic_data(self) -> dict:
|
| 496 |
-
"""Refresh all displayed cells from historic data for selected_date."""
|
| 497 |
-
try:
|
| 498 |
-
data = self.stock_data.get_data_for_date(self.selected_date)
|
| 499 |
-
except Exception as e:
|
| 500 |
-
logging.error(f"Failed to refresh historic data: {e}")
|
| 501 |
-
return {"ok": False, "message": str(e)}
|
| 502 |
-
with self._lock:
|
| 503 |
-
historic = {str(r['SYMBOL']): r for _, r in data.iterrows()}
|
| 504 |
-
removed = []
|
| 505 |
-
for cell in list(self.cells):
|
| 506 |
-
row = historic.get(cell.symbol)
|
| 507 |
-
if row is not None:
|
| 508 |
-
cell.current_cmp = float(row['CLOSE_PRICE'])
|
| 509 |
-
cell.prev_close = float(row['PREV_CL_PR'])
|
| 510 |
-
else:
|
| 511 |
-
logging.warning(
|
| 512 |
-
f"Historic data not found for {cell.symbol}. Removing cell."
|
| 513 |
-
)
|
| 514 |
-
self.cells.remove(cell)
|
| 515 |
-
removed.append(cell.symbol)
|
| 516 |
-
if removed:
|
| 517 |
-
self.save_dashboard_state()
|
| 518 |
-
return {"ok": True, "removed": removed}
|
| 519 |
-
|
| 520 |
-
# ==================================================================
|
| 521 |
-
# Portfolio summary
|
| 522 |
-
# ==================================================================
|
| 523 |
-
|
| 524 |
-
def portfolio_summary(self) -> dict:
|
| 525 |
-
cells = []
|
| 526 |
-
for c in self.cells:
|
| 527 |
-
key = self.key_for_symbol(c.symbol)
|
| 528 |
-
prev_close = 0.0
|
| 529 |
-
if key and 'PREV_CL_PR' in self.stocks[key]:
|
| 530 |
-
prev_close = float(self.stocks[key]['PREV_CL_PR'])
|
| 531 |
-
elif c.current_cmp != 0.0:
|
| 532 |
-
prev_close = c.current_cmp
|
| 533 |
-
cells.append({
|
| 534 |
-
"Quantity": c.quantity, "Price": c.price,
|
| 535 |
-
"current_cmp": c.current_cmp, "prev_close": prev_close,
|
| 536 |
-
})
|
| 537 |
-
return compute_portfolio_summary(cells)
|
| 538 |
-
|
| 539 |
-
# ==================================================================
|
| 540 |
-
# Watchlists
|
| 541 |
-
# ==================================================================
|
| 542 |
-
|
| 543 |
-
def save_dashboard_state(self) -> None:
|
| 544 |
-
self.watchlists.save_dashboard_state([c.get_data() for c in self.cells])
|
| 545 |
-
|
| 546 |
-
def _load_cells_from_entries(self, entries: list[dict],
|
| 547 |
-
live_data_map: dict | None = None) -> int:
|
| 548 |
-
"""Rebuild the board from watchlist entries; returns count loaded."""
|
| 549 |
-
self.delete_all_stocks(mark_modified=False)
|
| 550 |
-
loaded = 0
|
| 551 |
-
for stock_info in entries:
|
| 552 |
-
symbol = stock_info.get('SYMBOL')
|
| 553 |
-
key = self.key_for_symbol(symbol)
|
| 554 |
-
if key:
|
| 555 |
-
cell = self.add_stock(
|
| 556 |
-
key,
|
| 557 |
-
quantity=stock_info.get('Quantity', 0.0),
|
| 558 |
-
price=stock_info.get('Price', 0.0),
|
| 559 |
-
save_state=False,
|
| 560 |
-
)
|
| 561 |
-
if cell and live_data_map and symbol in live_data_map:
|
| 562 |
-
self.apply_live_quotes(
|
| 563 |
-
[live_data_map[symbol]], self.live_data_source
|
| 564 |
-
)
|
| 565 |
-
loaded += 1 if cell else 0
|
| 566 |
-
return loaded
|
| 567 |
-
|
| 568 |
-
def load_dashboard_state_by_name(self, watchlist_name: str) -> dict:
|
| 569 |
-
"""Restore the dashboard from a named watchlist (v4.5 parity)."""
|
| 570 |
-
try:
|
| 571 |
-
file_path = None
|
| 572 |
-
if watchlist_name == "Default List":
|
| 573 |
-
file_path = self.paths.state_file
|
| 574 |
-
else:
|
| 575 |
-
for i, name in enumerate(self.watchlist_names):
|
| 576 |
-
if name == watchlist_name:
|
| 577 |
-
file_path = self.paths.watchlist_slot(i + 1)
|
| 578 |
-
break
|
| 579 |
-
if not file_path or not file_path.exists():
|
| 580 |
-
logging.warning(
|
| 581 |
-
f"Watchlist file not found for '{watchlist_name}'."
|
| 582 |
-
)
|
| 583 |
-
self.delete_all_stocks(mark_modified=False)
|
| 584 |
-
return {"ok": False, "message": "Watchlist file not found."}
|
| 585 |
-
_name, entries = self.watchlists.load_symbols_from_json(file_path)
|
| 586 |
-
self._load_cells_from_entries(entries)
|
| 587 |
-
with self._lock:
|
| 588 |
-
self.watchlist_modified = False
|
| 589 |
-
self.current_watchlist_name = watchlist_name
|
| 590 |
-
return {"ok": True, "loaded": len(self.cells)}
|
| 591 |
-
except Exception as e:
|
| 592 |
-
logging.error(
|
| 593 |
-
f"Failed to load dashboard state for '{watchlist_name}': {e}"
|
| 594 |
-
)
|
| 595 |
-
self.delete_all_stocks(mark_modified=False)
|
| 596 |
-
self.current_watchlist_name = "Default List"
|
| 597 |
-
return {"ok": False, "message": f"Failed to load dashboard: {e}"}
|
| 598 |
-
|
| 599 |
-
def load_watchlist(self, watchlist_num: int) -> dict:
|
| 600 |
-
"""Load a numbered watchlist slot (v4.5 load_watchlist)."""
|
| 601 |
-
try:
|
| 602 |
-
file_path = self.paths.watchlist_slot(watchlist_num)
|
| 603 |
-
name, entries = self.watchlists.load_symbols_from_json(file_path)
|
| 604 |
-
if not entries:
|
| 605 |
-
self.delete_all_stocks(mark_modified=False)
|
| 606 |
-
self.current_watchlist_name = "Default List"
|
| 607 |
-
return {"ok": False,
|
| 608 |
-
"message": f"Watchlist {watchlist_num} is empty."}
|
| 609 |
-
if not name:
|
| 610 |
-
name = f"Watchlist {watchlist_num}"
|
| 611 |
-
self._load_cells_from_entries(entries)
|
| 612 |
-
with self._lock:
|
| 613 |
-
self.current_watchlist_name = name
|
| 614 |
-
self.watchlist_modified = False
|
| 615 |
-
self.save_dashboard_state()
|
| 616 |
-
return {"ok": True, "name": name, "loaded": len(self.cells)}
|
| 617 |
-
except Exception as e:
|
| 618 |
-
self.current_watchlist_name = "Default List"
|
| 619 |
-
return {"ok": False, "message": f"Failed to load watchlist: {e}"}
|
| 620 |
-
|
| 621 |
-
def load_watchlist_from_file(self, file_path: Path) -> dict:
|
| 622 |
-
"""Load a watchlist from an arbitrary JSON file (v4.5 parity,
|
| 623 |
-
including the live-quote prefetch when live mode is active)."""
|
| 624 |
-
try:
|
| 625 |
-
target_path = Path(file_path)
|
| 626 |
-
if not target_path.exists():
|
| 627 |
-
global_path = self.paths.data_dir / 'watchlists' / target_path.name
|
| 628 |
-
if global_path.exists():
|
| 629 |
-
target_path = global_path
|
| 630 |
-
|
| 631 |
-
name, entries = self.watchlists.load_symbols_from_json(target_path)
|
| 632 |
-
if not entries:
|
| 633 |
-
return {"ok": False,
|
| 634 |
-
"message": f"The watchlist '{target_path.stem}' is empty or file not found."}
|
| 635 |
-
if not name:
|
| 636 |
-
name = target_path.stem
|
| 637 |
-
live_map: dict = {}
|
| 638 |
-
if self.real_time_active:
|
| 639 |
-
symbols = [e['SYMBOL'] for e in entries]
|
| 640 |
-
quotes, source = self.stock_data.fetch_real_time_data(
|
| 641 |
-
symbols,
|
| 642 |
-
preferred_source=self.live_data_source_preference or "Google",
|
| 643 |
-
)
|
| 644 |
-
live_map = {q['SYMBOL']: q for q in quotes}
|
| 645 |
-
self.live_data_source = source
|
| 646 |
-
self._load_cells_from_entries(entries, live_map)
|
| 647 |
-
with self._lock:
|
| 648 |
-
self.current_watchlist_name = name
|
| 649 |
-
self.save_dashboard_state()
|
| 650 |
-
return {"ok": True, "name": name, "loaded": len(self.cells),
|
| 651 |
-
"message": f"Successfully loaded {len(self.cells)} "
|
| 652 |
-
f"stocks from '{name}'."}
|
| 653 |
-
except Exception as e:
|
| 654 |
-
logging.error(f"Error loading custom watchlist: {e}")
|
| 655 |
-
return {"ok": False,
|
| 656 |
-
"message": f"Failed to load custom watchlist: {e}"}
|
| 657 |
-
|
| 658 |
-
def save_dashboard(self) -> dict:
|
| 659 |
-
"""Save the board to the currently loaded named slot."""
|
| 660 |
-
if not self.cells:
|
| 661 |
-
return {"ok": False, "message": "No stocks to save."}
|
| 662 |
-
try:
|
| 663 |
-
idx = self.watchlist_names.index(self.current_watchlist_name)
|
| 664 |
-
except ValueError:
|
| 665 |
-
return {"ok": False, "needs_save_as": True,
|
| 666 |
-
"message": "Current view is not a saved watchlist. "
|
| 667 |
-
"Use 'Save as Watchlist'."}
|
| 668 |
-
try:
|
| 669 |
-
self.watchlists.save_slot(
|
| 670 |
-
idx + 1, self.current_watchlist_name,
|
| 671 |
-
[c.get_data() for c in self.cells],
|
| 672 |
-
)
|
| 673 |
-
self.watchlist_modified = False
|
| 674 |
-
self.watchlists.save_config(self.current_watchlist_name)
|
| 675 |
-
return {"ok": True,
|
| 676 |
-
"message": f"Dashboard saved to: "
|
| 677 |
-
f"{self.current_watchlist_name}"}
|
| 678 |
-
except Exception as e:
|
| 679 |
-
return {"ok": False, "message": f"Failed to save dashboard: {e}"}
|
| 680 |
-
|
| 681 |
-
def save_as_watchlist(self, watchlist_num: int, name: str) -> dict:
|
| 682 |
-
"""Save the board to a specific slot under a new name."""
|
| 683 |
-
if not self.cells:
|
| 684 |
-
return {"ok": False, "message": "No stocks to save"}
|
| 685 |
-
if not (1 <= int(watchlist_num) <= WATCHLIST_SLOTS):
|
| 686 |
-
return {"ok": False,
|
| 687 |
-
"message": f"Slot must be 1..{WATCHLIST_SLOTS}."}
|
| 688 |
-
name = (name or "").strip() or f"Watchlist {watchlist_num}"
|
| 689 |
-
try:
|
| 690 |
-
self.watchlists.save_slot(
|
| 691 |
-
int(watchlist_num), name, [c.get_data() for c in self.cells]
|
| 692 |
-
)
|
| 693 |
-
self.current_watchlist_name = name
|
| 694 |
-
self.watchlist_names = self.watchlists.load_watchlist_names()
|
| 695 |
-
self.watchlist_modified = False
|
| 696 |
-
self.watchlists.save_config(self.current_watchlist_name)
|
| 697 |
-
return {"ok": True, "message": f"Saved as {name}"}
|
| 698 |
-
except Exception as e:
|
| 699 |
-
return {"ok": False, "message": f"Failed to save watchlist: {e}"}
|
| 700 |
-
|
| 701 |
-
def add_selected_to_watchlist(self, watchlist_num: int) -> dict:
|
| 702 |
-
"""Merge checkbox-selected symbols into a slot (v4.5 parity,
|
| 703 |
-
including clearing the selections afterwards)."""
|
| 704 |
-
selected = self.selected_symbols_for_watchlist
|
| 705 |
-
if not selected:
|
| 706 |
-
return {"ok": False,
|
| 707 |
-
"message": "Please select at least one stock using "
|
| 708 |
-
"the checkboxes."}
|
| 709 |
-
try:
|
| 710 |
-
name, added = self.watchlists.add_symbols_to_slot(
|
| 711 |
-
int(watchlist_num), selected
|
| 712 |
-
)
|
| 713 |
-
except Exception as e:
|
| 714 |
-
return {"ok": False,
|
| 715 |
-
"message": f"Failed to add stocks to watchlist: {e}"}
|
| 716 |
-
with self._lock:
|
| 717 |
-
for c in self.cells:
|
| 718 |
-
c.selected = False
|
| 719 |
-
if added > 0:
|
| 720 |
-
self.watchlist_modified = True
|
| 721 |
-
self.watchlist_names = self.watchlists.load_watchlist_names()
|
| 722 |
-
return {"ok": True,
|
| 723 |
-
"message": f"Added {added} stock(s) to '{name}'."}
|
| 724 |
-
return {"ok": True,
|
| 725 |
-
"message": "All selected stocks are already in the "
|
| 726 |
-
"chosen watchlist."}
|
| 727 |
-
|
| 728 |
-
# ==================================================================
|
| 729 |
-
# Index / analysis board loads
|
| 730 |
-
# ==================================================================
|
| 731 |
-
|
| 732 |
-
def load_index_stocks(self, index_name: str) -> dict:
|
| 733 |
-
stock_list = {"SENSEX": SENSEX_STOCKS, "NIFTY": NIFTY_STOCKS}.get(index_name)
|
| 734 |
-
if stock_list is None:
|
| 735 |
-
return {"ok": False, "message": f"Unknown index: {index_name}"}
|
| 736 |
-
self.delete_all_stocks(mark_modified=False)
|
| 737 |
-
for symbol in stock_list:
|
| 738 |
-
key = self.key_for_symbol(symbol)
|
| 739 |
-
if key:
|
| 740 |
-
self.add_stock(key, save_state=False)
|
| 741 |
-
self.current_watchlist_name = f"{index_name} Index"
|
| 742 |
-
self.save_dashboard_state()
|
| 743 |
-
return {"ok": True, "loaded": len(self.cells)}
|
| 744 |
-
|
| 745 |
-
def display_analysis_result(self, symbols: list[str],
|
| 746 |
-
json_name: str, cached: bool) -> dict:
|
| 747 |
-
"""Common tail of every analysis flow: rebuild board from symbols."""
|
| 748 |
-
if not symbols:
|
| 749 |
-
return {"ok": False, "message": "No stock data available for filter."}
|
| 750 |
-
self.delete_all_stocks(mark_modified=False)
|
| 751 |
-
for symbol in symbols:
|
| 752 |
-
key = self.key_for_symbol(symbol)
|
| 753 |
-
if key:
|
| 754 |
-
self.add_stock(key, save_state=False)
|
| 755 |
-
self.save_dashboard_state()
|
| 756 |
-
self.current_watchlist_name = json_name.replace(" Portfolio", "")
|
| 757 |
-
message = (
|
| 758 |
-
f"Loaded {len(self.cells)} stocks from saved file."
|
| 759 |
-
if cached else
|
| 760 |
-
f"Displayed {len(self.cells)} stocks after calculation."
|
| 761 |
-
)
|
| 762 |
-
return {"ok": True, "message": message, "loaded": len(self.cells)}
|
| 763 |
-
|
| 764 |
-
def load_portfolio_holdings(self, holdings: list[dict],
|
| 765 |
-
unmapped: list[str], broker_name: str = "ICICIDirect") -> dict:
|
| 766 |
-
"""Board rebuild after portfolio import, automatically saving to Watchlist 2."""
|
| 767 |
-
if not holdings:
|
| 768 |
-
return {"ok": False, "message": "No stocks mapped."}
|
| 769 |
-
with self._lock:
|
| 770 |
-
self.delete_all_stocks(mark_modified=False)
|
| 771 |
-
loaded = 0
|
| 772 |
-
for h in holdings:
|
| 773 |
-
key = self.key_for_symbol(h['SYMBOL'])
|
| 774 |
-
if key:
|
| 775 |
-
self.add_stock(key, quantity=h['Quantity'],
|
| 776 |
-
price=h['Price'], save_state=False)
|
| 777 |
-
loaded += 1
|
| 778 |
-
self.save_dashboard_state()
|
| 779 |
-
|
| 780 |
-
# Auto-save to watchlist slot 2 and rename it
|
| 781 |
-
watchlist_name = f"{broker_name} Portfolio"
|
| 782 |
-
self.watchlists.save_slot(
|
| 783 |
-
2, watchlist_name, [c.get_data() for c in self.cells]
|
| 784 |
-
)
|
| 785 |
-
self.current_watchlist_name = watchlist_name
|
| 786 |
-
self.watchlist_names = self.watchlists.load_watchlist_names()
|
| 787 |
-
self.watchlist_modified = False
|
| 788 |
-
self.watchlists.persist_config() if hasattr(self.watchlists, 'persist_config') else self.watchlists.save_config(self.current_watchlist_name)
|
| 789 |
-
|
| 790 |
-
msg = f"Successfully loaded {loaded} stocks from {broker_name}."
|
| 791 |
-
if unmapped:
|
| 792 |
-
msg += f" Could not map: {', '.join(str(u) for u in unmapped)}"
|
| 793 |
-
return {"ok": True, "loaded": loaded, "unmapped": unmapped,
|
| 794 |
-
"message": msg}
|
| 795 |
-
|
| 796 |
-
# ==================================================================
|
| 797 |
-
# Date change / refresh / export / config
|
| 798 |
-
# ==================================================================
|
| 799 |
-
|
| 800 |
-
def change_date(self, new_date_str: str) -> dict:
|
| 801 |
-
"""v4.5 on_date_change: reload universe and update board cells."""
|
| 802 |
-
try:
|
| 803 |
-
new_date = datetime.strptime(new_date_str, '%Y-%m-%d').date()
|
| 804 |
-
except ValueError:
|
| 805 |
-
return {"ok": False, "message": "Invalid date format (YYYY-MM-DD)."}
|
| 806 |
-
if new_date > datetime.now().date():
|
| 807 |
-
return {"ok": False, "message": "Cannot select future dates."}
|
| 808 |
-
old_date = self.selected_date
|
| 809 |
-
self.selected_date = new_date
|
| 810 |
-
try:
|
| 811 |
-
self.load_universe()
|
| 812 |
-
except ValueError:
|
| 813 |
-
self.selected_date = old_date
|
| 814 |
-
return {"ok": False,
|
| 815 |
-
"message": f"No stock data available for {new_date}."}
|
| 816 |
-
except Exception as e:
|
| 817 |
-
self.selected_date = old_date
|
| 818 |
-
return {"ok": False,
|
| 819 |
-
"message": f"Failed to load data for {new_date}: {e}"}
|
| 820 |
-
with self._lock:
|
| 821 |
-
removed = []
|
| 822 |
-
for cell in list(self.cells):
|
| 823 |
-
key = self.key_for_symbol(cell.symbol)
|
| 824 |
-
if key:
|
| 825 |
-
row = self.stocks[key]
|
| 826 |
-
cell.current_cmp = float(row['CLOSE_PRICE'])
|
| 827 |
-
cell.prev_close = float(row['PREV_CL_PR'])
|
| 828 |
-
else:
|
| 829 |
-
self.cells.remove(cell)
|
| 830 |
-
removed.append(cell.symbol)
|
| 831 |
-
if removed:
|
| 832 |
-
self.save_dashboard_state()
|
| 833 |
-
return {"ok": True,
|
| 834 |
-
"message": f"Dashboard updated for {self.selected_date}.",
|
| 835 |
-
"removed": removed}
|
| 836 |
-
|
| 837 |
-
def refresh_dashboard(self) -> dict:
|
| 838 |
-
"""v4.5 refresh_dashboard: reload CSV + rebuild universe + re-add."""
|
| 839 |
-
stopped_live = False
|
| 840 |
-
if self.real_time_active:
|
| 841 |
-
self.stop_live()
|
| 842 |
-
stopped_live = True
|
| 843 |
-
try:
|
| 844 |
-
self.stock_data.load_data()
|
| 845 |
-
except Exception as e:
|
| 846 |
-
return {"ok": False,
|
| 847 |
-
"message": f"Failed to reload data: {e}\n"
|
| 848 |
-
"Dashboard might not be fully updated."}
|
| 849 |
-
current = {c.symbol: c.get_data() for c in self.cells}
|
| 850 |
-
try:
|
| 851 |
-
self.load_universe()
|
| 852 |
-
self.delete_all_stocks(mark_modified=False)
|
| 853 |
-
reloaded = 0
|
| 854 |
-
for symbol, old in current.items():
|
| 855 |
-
key = self.key_for_symbol(symbol)
|
| 856 |
-
if key:
|
| 857 |
-
self.add_stock(key, quantity=old['Quantity'],
|
| 858 |
-
price=old['Price'], save_state=False)
|
| 859 |
-
reloaded += 1
|
| 860 |
-
self.save_dashboard_state()
|
| 861 |
-
msg = f"Data reloaded. {reloaded} stocks re-displayed."
|
| 862 |
-
if stopped_live:
|
| 863 |
-
msg = "Live data updates have been paused for dashboard " \
|
| 864 |
-
"refresh. " + msg
|
| 865 |
-
return {"ok": True, "message": msg, "reloaded": reloaded}
|
| 866 |
-
except Exception as e:
|
| 867 |
-
return {"ok": False,
|
| 868 |
-
"message": f"An error occurred while updating the "
|
| 869 |
-
f"dashboard: {e}"}
|
| 870 |
-
|
| 871 |
-
def export_to_csv(self) -> dict:
|
| 872 |
-
"""Export cell data to a timestamped CSV in downloads/."""
|
| 873 |
-
try:
|
| 874 |
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 875 |
-
filename = self.paths.log_dir / f"stock_data_{timestamp}.csv"
|
| 876 |
-
pd.DataFrame([c.get_data() for c in self.cells]).to_csv(
|
| 877 |
-
filename, index=False
|
| 878 |
-
)
|
| 879 |
-
return {"ok": True, "file": str(filename),
|
| 880 |
-
"message": f"Data exported to: {filename}"}
|
| 881 |
-
except Exception as e:
|
| 882 |
-
logging.error(f"Failed to export data to CSV: {e}")
|
| 883 |
-
return {"ok": False,
|
| 884 |
-
"message": "Failed to export data. Check logs."}
|
| 885 |
-
|
| 886 |
-
def persist_config(self) -> None:
|
| 887 |
-
self.watchlists.save_config(self.current_watchlist_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/hf_sync.py
DELETED
|
@@ -1,338 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.hf_sync
|
| 3 |
-
====================
|
| 4 |
-
Two-way synchronisation between the local ``processed/`` directory and
|
| 5 |
-
the Hugging Face dataset ``Sam-max1/nsedash_data``.
|
| 6 |
-
|
| 7 |
-
Active ONLY when ``SPACE_ID`` is set in the environment (i.e. inside a
|
| 8 |
-
Hugging Face Space). In local/dev mode this module is a complete no-op
|
| 9 |
-
so that local runs are unaffected.
|
| 10 |
-
|
| 11 |
-
Key design decisions
|
| 12 |
-
---------------------
|
| 13 |
-
* **Startup pull** — ``pull_on_startup()`` is called *before* Flask starts
|
| 14 |
-
and *before* ``DashboardService`` is created so that processed data is
|
| 15 |
-
fresh on every Space restart. It is synchronous (blocks startup) to
|
| 16 |
-
guarantee correctness.
|
| 17 |
-
* **Async push** — every local file write triggers ``push_file_async()``
|
| 18 |
-
which queues the upload on a background daemon thread. The main request
|
| 19 |
-
path is never blocked.
|
| 20 |
-
* **3-second debounce** — a timer is reset on each push call for the same
|
| 21 |
-
path so that rapid sequential writes (e.g. saving all watchlist slots at
|
| 22 |
-
once) are coalesced into a single upload.
|
| 23 |
-
* **Session cleanup** — ``delete_session_files(token)`` is called when a
|
| 24 |
-
session expires so per-user files are removed from the dataset, keeping
|
| 25 |
-
the repo clean.
|
| 26 |
-
* **Read-only parquet guard** — the large ``nse_processed_data.parquet``
|
| 27 |
-
is pulled on startup but never pushed at runtime (it is only updated via
|
| 28 |
-
``push_to_hf.py``).
|
| 29 |
-
"""
|
| 30 |
-
|
| 31 |
-
from __future__ import annotations
|
| 32 |
-
|
| 33 |
-
import logging
|
| 34 |
-
import os
|
| 35 |
-
import threading
|
| 36 |
-
from pathlib import Path
|
| 37 |
-
from typing import TYPE_CHECKING
|
| 38 |
-
|
| 39 |
-
if TYPE_CHECKING:
|
| 40 |
-
pass
|
| 41 |
-
|
| 42 |
-
log = logging.getLogger(__name__)
|
| 43 |
-
|
| 44 |
-
# Files that are pulled on startup but NEVER pushed at runtime (too large /
|
| 45 |
-
# managed by push_to_hf.py instead).
|
| 46 |
-
_READ_ONLY_FILES = frozenset({
|
| 47 |
-
"nse_processed_data.parquet",
|
| 48 |
-
"nse_summary_data.parquet",
|
| 49 |
-
})
|
| 50 |
-
|
| 51 |
-
# Remote prefix inside the HF dataset repo for all processed files.
|
| 52 |
-
_REMOTE_PREFIX = "processed"
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
class HFDataSync:
|
| 56 |
-
"""
|
| 57 |
-
Two-way sync between ``processed/`` and ``Sam-max1/nsedash_data``.
|
| 58 |
-
|
| 59 |
-
Parameters
|
| 60 |
-
----------
|
| 61 |
-
data_dir:
|
| 62 |
-
Local path to ``<base_dir>/processed/``.
|
| 63 |
-
repo_id:
|
| 64 |
-
HuggingFace dataset repo ID (default: ``Sam-max1/nsedash_data``).
|
| 65 |
-
token:
|
| 66 |
-
HF API token. If *None*, tries ``HF_TOKEN`` env var.
|
| 67 |
-
enabled:
|
| 68 |
-
Forced override — set to ``False`` to disable even in HF mode.
|
| 69 |
-
"""
|
| 70 |
-
|
| 71 |
-
def __init__(
|
| 72 |
-
self,
|
| 73 |
-
data_dir: Path,
|
| 74 |
-
repo_id: str = "Sam-max1/nsedash_data",
|
| 75 |
-
token: str | None = None,
|
| 76 |
-
enabled: bool | None = None,
|
| 77 |
-
) -> None:
|
| 78 |
-
self.data_dir = Path(data_dir)
|
| 79 |
-
self.repo_id = repo_id
|
| 80 |
-
|
| 81 |
-
# Resolve token
|
| 82 |
-
self._token = token or os.environ.get("HF_TOKEN") or os.environ.get("HF_PRIVATE_TOKEN")
|
| 83 |
-
|
| 84 |
-
# Enable only when running inside a HF Space or HF mode is set (unless forced)
|
| 85 |
-
if enabled is None:
|
| 86 |
-
self.enabled = "SPACE_ID" in os.environ or os.environ.get("HF_MODE") == "1"
|
| 87 |
-
else:
|
| 88 |
-
self.enabled = enabled
|
| 89 |
-
|
| 90 |
-
self._api = None # lazy HfApi instance
|
| 91 |
-
self._lock = threading.Lock()
|
| 92 |
-
|
| 93 |
-
# Debounce: path → (Timer, repo_path)
|
| 94 |
-
self._pending: dict[str, threading.Timer] = {}
|
| 95 |
-
self._debounce_sec = 3.0
|
| 96 |
-
|
| 97 |
-
if self.enabled:
|
| 98 |
-
if not self._token:
|
| 99 |
-
log.warning(
|
| 100 |
-
"[HFSync] HF_TOKEN not set — HF dataset sync is disabled."
|
| 101 |
-
)
|
| 102 |
-
self.enabled = False
|
| 103 |
-
else:
|
| 104 |
-
log.info(
|
| 105 |
-
"[HFSync] Enabled — dataset: %s, data_dir: %s",
|
| 106 |
-
self.repo_id, self.data_dir,
|
| 107 |
-
)
|
| 108 |
-
else:
|
| 109 |
-
log.info("[HFSync] Disabled (not in HF Space mode).")
|
| 110 |
-
|
| 111 |
-
# ------------------------------------------------------------------
|
| 112 |
-
# Internal helpers
|
| 113 |
-
# ------------------------------------------------------------------
|
| 114 |
-
|
| 115 |
-
def _get_api(self):
|
| 116 |
-
"""Lazy-load HfApi (avoids import cost when sync is disabled)."""
|
| 117 |
-
if self._api is None:
|
| 118 |
-
from huggingface_hub import HfApi
|
| 119 |
-
self._api = HfApi(token=self._token)
|
| 120 |
-
return self._api
|
| 121 |
-
|
| 122 |
-
def _ensure_repo(self) -> None:
|
| 123 |
-
"""Create the dataset repo if it does not yet exist."""
|
| 124 |
-
try:
|
| 125 |
-
api = self._get_api()
|
| 126 |
-
api.create_repo(
|
| 127 |
-
repo_id=self.repo_id,
|
| 128 |
-
repo_type="dataset",
|
| 129 |
-
private=False,
|
| 130 |
-
exist_ok=True,
|
| 131 |
-
)
|
| 132 |
-
log.info("[HFSync] Dataset repo '%s' is ready.", self.repo_id)
|
| 133 |
-
except Exception as exc:
|
| 134 |
-
log.error("[HFSync] Could not ensure dataset repo exists: %s", exc)
|
| 135 |
-
|
| 136 |
-
def _remote_path(self, local_path: Path) -> str:
|
| 137 |
-
"""Compute the in-repo path for a local file under data_dir."""
|
| 138 |
-
try:
|
| 139 |
-
rel = local_path.relative_to(self.data_dir)
|
| 140 |
-
except ValueError:
|
| 141 |
-
rel = Path(local_path.name)
|
| 142 |
-
return f"{_REMOTE_PREFIX}/{rel.as_posix()}"
|
| 143 |
-
|
| 144 |
-
# ------------------------------------------------------------------
|
| 145 |
-
# Startup pull
|
| 146 |
-
# ------------------------------------------------------------------
|
| 147 |
-
|
| 148 |
-
def pull_on_startup(self) -> None:
|
| 149 |
-
"""
|
| 150 |
-
Download all files from the HF dataset into ``processed/``.
|
| 151 |
-
|
| 152 |
-
Runs synchronously during startup. Missing remote files are
|
| 153 |
-
silently skipped (first-run scenario). The local parquet is
|
| 154 |
-
never overwritten if it is larger than the remote copy (safety
|
| 155 |
-
guard for the large data file).
|
| 156 |
-
"""
|
| 157 |
-
if not self.enabled:
|
| 158 |
-
return
|
| 159 |
-
|
| 160 |
-
log.info("[HFSync] Starting startup pull from %s …", self.repo_id)
|
| 161 |
-
try:
|
| 162 |
-
self._ensure_repo()
|
| 163 |
-
api = self._get_api()
|
| 164 |
-
remote_files = list(api.list_repo_files(
|
| 165 |
-
repo_id=self.repo_id, repo_type="dataset"
|
| 166 |
-
))
|
| 167 |
-
except Exception as exc:
|
| 168 |
-
log.error("[HFSync] Could not list remote files: %s", exc)
|
| 169 |
-
return
|
| 170 |
-
|
| 171 |
-
pulled, skipped, failed = 0, 0, 0
|
| 172 |
-
for remote_path in remote_files:
|
| 173 |
-
# Skip HF metadata files
|
| 174 |
-
if remote_path.startswith("."):
|
| 175 |
-
continue
|
| 176 |
-
if not remote_path.startswith(f"{_REMOTE_PREFIX}/"):
|
| 177 |
-
skipped += 1
|
| 178 |
-
continue
|
| 179 |
-
|
| 180 |
-
rel = remote_path[len(_REMOTE_PREFIX) + 1:] # strip "processed/"
|
| 181 |
-
local_path = self.data_dir / rel
|
| 182 |
-
|
| 183 |
-
# Ensure local file is removed before pulling to guarantee overwriting
|
| 184 |
-
if local_path.exists() and local_path.is_file():
|
| 185 |
-
try:
|
| 186 |
-
local_path.unlink()
|
| 187 |
-
except Exception as e:
|
| 188 |
-
log.warning("[HFSync] Could not remove local file %s before pull: %s", local_path, e)
|
| 189 |
-
|
| 190 |
-
local_path.parent.mkdir(parents=True, exist_ok=True)
|
| 191 |
-
|
| 192 |
-
try:
|
| 193 |
-
from huggingface_hub import hf_hub_download
|
| 194 |
-
tmp = hf_hub_download(
|
| 195 |
-
repo_id=self.repo_id,
|
| 196 |
-
repo_type="dataset",
|
| 197 |
-
filename=remote_path,
|
| 198 |
-
token=self._token,
|
| 199 |
-
local_dir=str(self.data_dir.parent), # downloads into parent/processed/rel
|
| 200 |
-
local_dir_use_symlinks=False,
|
| 201 |
-
)
|
| 202 |
-
# hf_hub_download places file at local_dir/<remote_path>
|
| 203 |
-
# which resolves to data_dir.parent/processed/rel = data_dir/rel ✓
|
| 204 |
-
pulled += 1
|
| 205 |
-
log.info("[HFSync] Pulled: %s", rel)
|
| 206 |
-
except Exception as exc:
|
| 207 |
-
log.warning("[HFSync] Could not pull '%s': %s", remote_path, exc)
|
| 208 |
-
failed += 1
|
| 209 |
-
|
| 210 |
-
log.info(
|
| 211 |
-
"[HFSync] Startup pull complete — pulled=%d, skipped=%d, failed=%d",
|
| 212 |
-
pulled, skipped, failed,
|
| 213 |
-
)
|
| 214 |
-
|
| 215 |
-
# ------------------------------------------------------------------
|
| 216 |
-
# Async push (write path)
|
| 217 |
-
# ------------------------------------------------------------------
|
| 218 |
-
|
| 219 |
-
def push_file_async(self, local_path: Path | str,
|
| 220 |
-
repo_path: str | None = None) -> None:
|
| 221 |
-
"""
|
| 222 |
-
Schedule an upload of *local_path* to the HF dataset.
|
| 223 |
-
|
| 224 |
-
Calls within ``debounce_sec`` for the same path are coalesced —
|
| 225 |
-
only the last write is uploaded. This is a fire-and-forget
|
| 226 |
-
background thread; the caller is never blocked.
|
| 227 |
-
"""
|
| 228 |
-
if not self.enabled:
|
| 229 |
-
return
|
| 230 |
-
|
| 231 |
-
local_path = Path(local_path)
|
| 232 |
-
if not local_path.exists():
|
| 233 |
-
return
|
| 234 |
-
|
| 235 |
-
# Never push read-only / large files at runtime
|
| 236 |
-
if local_path.name in _READ_ONLY_FILES:
|
| 237 |
-
return
|
| 238 |
-
|
| 239 |
-
repo_path = repo_path or self._remote_path(local_path)
|
| 240 |
-
key = str(local_path)
|
| 241 |
-
|
| 242 |
-
with self._lock:
|
| 243 |
-
# Cancel any pending timer for this path
|
| 244 |
-
existing = self._pending.pop(key, None)
|
| 245 |
-
if existing:
|
| 246 |
-
existing.cancel()
|
| 247 |
-
|
| 248 |
-
def _upload():
|
| 249 |
-
with self._lock:
|
| 250 |
-
self._pending.pop(key, None)
|
| 251 |
-
try:
|
| 252 |
-
api = self._get_api()
|
| 253 |
-
api.upload_file(
|
| 254 |
-
path_or_fileobj=str(local_path),
|
| 255 |
-
path_in_repo=repo_path,
|
| 256 |
-
repo_id=self.repo_id,
|
| 257 |
-
repo_type="dataset",
|
| 258 |
-
commit_message=f"auto: update {local_path.name}",
|
| 259 |
-
)
|
| 260 |
-
log.info("[HFSync] Pushed: %s → %s", local_path.name, repo_path)
|
| 261 |
-
except Exception as exc:
|
| 262 |
-
log.warning(
|
| 263 |
-
"[HFSync] Push failed for '%s': %s", local_path.name, exc
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
timer = threading.Timer(self._debounce_sec, _upload)
|
| 267 |
-
timer.daemon = True
|
| 268 |
-
timer.start()
|
| 269 |
-
self._pending[key] = timer
|
| 270 |
-
|
| 271 |
-
# ------------------------------------------------------------------
|
| 272 |
-
# Session cleanup (delete per-user files from HF dataset)
|
| 273 |
-
# ------------------------------------------------------------------
|
| 274 |
-
|
| 275 |
-
def delete_session_files(self, token: str) -> None:
|
| 276 |
-
"""
|
| 277 |
-
Remove per-user files for *token* from the HF dataset.
|
| 278 |
-
|
| 279 |
-
Called when a session expires or the user disconnects, to keep
|
| 280 |
-
the dataset clean. Runs in a background thread.
|
| 281 |
-
"""
|
| 282 |
-
if not self.enabled or not token or token in ("admin", "default"):
|
| 283 |
-
return
|
| 284 |
-
|
| 285 |
-
def _delete():
|
| 286 |
-
try:
|
| 287 |
-
api = self._get_api()
|
| 288 |
-
remote_files = list(api.list_repo_files(
|
| 289 |
-
repo_id=self.repo_id, repo_type="dataset"
|
| 290 |
-
))
|
| 291 |
-
suffix = f"_{token}"
|
| 292 |
-
targets = [
|
| 293 |
-
f for f in remote_files
|
| 294 |
-
if token in f and not f.startswith(".")
|
| 295 |
-
]
|
| 296 |
-
for path in targets:
|
| 297 |
-
try:
|
| 298 |
-
api.delete_file(
|
| 299 |
-
path_in_repo=path,
|
| 300 |
-
repo_id=self.repo_id,
|
| 301 |
-
repo_type="dataset",
|
| 302 |
-
commit_message=f"cleanup: session {token[:8]} expired",
|
| 303 |
-
)
|
| 304 |
-
log.info("[HFSync] Deleted session file: %s", path)
|
| 305 |
-
except Exception as exc:
|
| 306 |
-
log.warning(
|
| 307 |
-
"[HFSync] Could not delete '%s': %s", path, exc
|
| 308 |
-
)
|
| 309 |
-
except Exception as exc:
|
| 310 |
-
log.error(
|
| 311 |
-
"[HFSync] Session cleanup failed for token %s: %s",
|
| 312 |
-
token[:8], exc,
|
| 313 |
-
)
|
| 314 |
-
|
| 315 |
-
threading.Thread(target=_delete, daemon=True, name=f"hfsync-cleanup-{token[:8]}").start()
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
# ---------------------------------------------------------------------------
|
| 319 |
-
# Module-level no-op singleton (used when sync is disabled)
|
| 320 |
-
# ---------------------------------------------------------------------------
|
| 321 |
-
|
| 322 |
-
class _NoOpSync:
|
| 323 |
-
"""Drop-in replacement when HF sync is disabled — all methods are no-ops."""
|
| 324 |
-
enabled = False
|
| 325 |
-
|
| 326 |
-
def pull_on_startup(self) -> None: pass
|
| 327 |
-
def push_file_async(self, *_a, **_kw) -> None: pass
|
| 328 |
-
def delete_session_files(self, *_a, **_kw) -> None: pass
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
def make_sync(data_dir: Path, repo_id: str, token: str | None = None) -> HFDataSync | _NoOpSync:
|
| 332 |
-
"""
|
| 333 |
-
Factory — returns a live ``HFDataSync`` when in HF mode, else a no-op.
|
| 334 |
-
"""
|
| 335 |
-
is_hf = "SPACE_ID" in os.environ or os.environ.get("HF_MODE") == "1"
|
| 336 |
-
if not is_hf:
|
| 337 |
-
return _NoOpSync()
|
| 338 |
-
return HFDataSync(data_dir=data_dir, repo_id=repo_id, token=token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/live_fetch.py
DELETED
|
@@ -1,361 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.live_fetch
|
| 3 |
-
=======================
|
| 4 |
-
Real-time price/volume scraping from Google Finance and Yahoo Finance,
|
| 5 |
-
with per-symbol source failover — functional parity with v4.5's
|
| 6 |
-
``fetch_real_time_data_gf`` / ``fetch_real_time_data_yf`` /
|
| 7 |
-
``fetch_real_time_data``.
|
| 8 |
-
|
| 9 |
-
Audit fixes applied here:
|
| 10 |
-
C-1 Every HTTP call now has an explicit timeout (original had none: a
|
| 11 |
-
single hung socket froze the refresh cycle indefinitely).
|
| 12 |
-
P-1 Symbols are fetched concurrently via ThreadPoolExecutor instead of
|
| 13 |
-
strictly sequentially (original: ~2 blocking requests per symbol,
|
| 14 |
-
serially — 50 symbols ≈ 100 serial round trips).
|
| 15 |
-
P-2 A shared ``requests.Session`` reuses TCP/TLS connections.
|
| 16 |
-
A-1 ``fetch_real_time_volume`` returned ``([], [])`` on error but
|
| 17 |
-
``(float, float)`` on success; the type anomaly is fixed —
|
| 18 |
-
always returns ``(float, float)``.
|
| 19 |
-
P-3 The Google path no longer performs a second full Yahoo page fetch
|
| 20 |
-
just for volume when the Yahoo page was already fetched during
|
| 21 |
-
failover; volume scraping is still done for Google-sourced quotes
|
| 22 |
-
(behaviour retained) but through the shared session.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
from __future__ import annotations
|
| 26 |
-
|
| 27 |
-
import logging
|
| 28 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 29 |
-
from datetime import datetime
|
| 30 |
-
from typing import Callable, Iterable
|
| 31 |
-
|
| 32 |
-
import json
|
| 33 |
-
import requests
|
| 34 |
-
from bs4 import BeautifulSoup
|
| 35 |
-
|
| 36 |
-
from ..config import FETCH_MAX_WORKERS, HTTP_TIMEOUT_SEC, REQUEST_HEADERS
|
| 37 |
-
|
| 38 |
-
PrevVolumeLookup = Callable[[str], float]
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
import threading
|
| 42 |
-
|
| 43 |
-
_global_fetch_lock = threading.Lock()
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class LiveFetcher:
|
| 47 |
-
"""
|
| 48 |
-
Scrapes live quotes. ``prev_volume_lookup`` is injected by the data
|
| 49 |
-
layer so this module stays free of DataFrame coupling.
|
| 50 |
-
"""
|
| 51 |
-
|
| 52 |
-
def __init__(self, prev_volume_lookup: PrevVolumeLookup | None = None,
|
| 53 |
-
session: requests.Session | None = None):
|
| 54 |
-
self.prev_volume_lookup = prev_volume_lookup or (lambda _s: 0.0)
|
| 55 |
-
self.session = session or requests.Session()
|
| 56 |
-
self.session.headers.update(REQUEST_HEADERS)
|
| 57 |
-
|
| 58 |
-
# ------------------------------------------------------------------
|
| 59 |
-
# Volume (Yahoo)
|
| 60 |
-
# ------------------------------------------------------------------
|
| 61 |
-
|
| 62 |
-
def fetch_real_time_volume(self, symbol: str) -> tuple[float, float]:
|
| 63 |
-
"""Return (current_volume, previous_volume) for a symbol."""
|
| 64 |
-
current_volume, previous_volume = 0.0, 0.0
|
| 65 |
-
yahoo_symbol = f'{symbol}.NS'
|
| 66 |
-
try:
|
| 67 |
-
response = self.session.get(
|
| 68 |
-
f'https://finance.yahoo.com/quote/{yahoo_symbol}',
|
| 69 |
-
timeout=HTTP_TIMEOUT_SEC,
|
| 70 |
-
)
|
| 71 |
-
response.raise_for_status()
|
| 72 |
-
soup = BeautifulSoup(response.text, 'html.parser')
|
| 73 |
-
best_res = None
|
| 74 |
-
for s in soup.find_all('script'):
|
| 75 |
-
if s.string and 'quoteResponse' in s.string:
|
| 76 |
-
try:
|
| 77 |
-
data = json.loads(s.string)
|
| 78 |
-
body = json.loads(data['body'])
|
| 79 |
-
for r_item in body['quoteResponse']['result']:
|
| 80 |
-
if r_item.get('symbol') == yahoo_symbol:
|
| 81 |
-
if best_res is None or (r_item.get('regularMarketDayHigh') is not None and best_res.get('regularMarketDayHigh') is None):
|
| 82 |
-
best_res = r_item
|
| 83 |
-
except Exception:
|
| 84 |
-
pass
|
| 85 |
-
|
| 86 |
-
if best_res:
|
| 87 |
-
vol_val = best_res.get('regularMarketVolume')
|
| 88 |
-
if isinstance(vol_val, dict):
|
| 89 |
-
current_volume = float(vol_val.get('raw', 0.0))
|
| 90 |
-
elif vol_val is not None:
|
| 91 |
-
current_volume = float(vol_val)
|
| 92 |
-
else:
|
| 93 |
-
# Fallback to the old HTML parser
|
| 94 |
-
vol_tag = soup.find('fin-streamer', {'data-symbol': yahoo_symbol, 'data-field': 'regularMarketVolume'})
|
| 95 |
-
if vol_tag and vol_tag.text:
|
| 96 |
-
try:
|
| 97 |
-
current_volume = float(vol_tag.text.replace(',', ''))
|
| 98 |
-
except ValueError:
|
| 99 |
-
pass
|
| 100 |
-
except requests.exceptions.RequestException as req_err:
|
| 101 |
-
logging.error(f"HTTP Request error for {symbol} from Yahoo Finance: {req_err}")
|
| 102 |
-
return 0.0, 0.0
|
| 103 |
-
except Exception as e:
|
| 104 |
-
logging.error(f"Error fetching volume for {symbol} from Yahoo Finance: {e}")
|
| 105 |
-
return 0.0, 0.0
|
| 106 |
-
try:
|
| 107 |
-
previous_volume = float(self.prev_volume_lookup(symbol))
|
| 108 |
-
except Exception as e:
|
| 109 |
-
logging.error(f"Error fetching previous volume for {symbol} from historic data: {e}")
|
| 110 |
-
return current_volume, previous_volume
|
| 111 |
-
|
| 112 |
-
# ------------------------------------------------------------------
|
| 113 |
-
# Google Finance
|
| 114 |
-
# ------------------------------------------------------------------
|
| 115 |
-
|
| 116 |
-
def fetch_symbol_gf(self, symbol: str) -> dict | None:
|
| 117 |
-
"""Scrape one symbol from Google Finance. None on failure."""
|
| 118 |
-
try:
|
| 119 |
-
response = self.session.get(
|
| 120 |
-
f'https://www.google.com/finance/quote/{symbol}:NSE',
|
| 121 |
-
headers={'User-Agent': 'python-requests/2.32.5'},
|
| 122 |
-
timeout=HTTP_TIMEOUT_SEC,
|
| 123 |
-
)
|
| 124 |
-
response.raise_for_status()
|
| 125 |
-
soup = BeautifulSoup(response.text, 'html.parser')
|
| 126 |
-
|
| 127 |
-
price_element = soup.find('div', {'class': 'YMlKec fxKbKc'})
|
| 128 |
-
current_price = (
|
| 129 |
-
float(price_element.text[1:].replace(",", ""))
|
| 130 |
-
if price_element else 0.0
|
| 131 |
-
)
|
| 132 |
-
if current_price <= 0.0:
|
| 133 |
-
raise ValueError(f"Invalid or zero price scraped for {symbol}")
|
| 134 |
-
previous_price_element = soup.find('div', {'class': 'P6K39c'})
|
| 135 |
-
previous_close_price = (
|
| 136 |
-
float(previous_price_element.text[1:].replace(",", ""))
|
| 137 |
-
if previous_price_element else 0.0
|
| 138 |
-
)
|
| 139 |
-
all_p6k39c = soup.find_all('div', {'class': 'P6K39c'})
|
| 140 |
-
day_high = day_low = 0.0
|
| 141 |
-
if len(all_p6k39c) > 1:
|
| 142 |
-
range_text = all_p6k39c[1].text.replace('₹', '').replace(',', '')
|
| 143 |
-
if ' - ' in range_text:
|
| 144 |
-
low_str, high_str = range_text.split(' - ')
|
| 145 |
-
day_low, day_high = float(low_str), float(high_str)
|
| 146 |
-
if previous_close_price == 0.0:
|
| 147 |
-
previous_close_price = current_price
|
| 148 |
-
current_volume, previous_volume = self.fetch_real_time_volume(symbol)
|
| 149 |
-
return {
|
| 150 |
-
"SYMBOL": symbol,
|
| 151 |
-
"CLOSE_PRICE": current_price,
|
| 152 |
-
"PREV_CL_PR": previous_close_price,
|
| 153 |
-
"DAY_HIGH": day_high,
|
| 154 |
-
"DAY_LOW": day_low,
|
| 155 |
-
"CURR_VOL": current_volume,
|
| 156 |
-
"PREV_VOL": previous_volume,
|
| 157 |
-
}
|
| 158 |
-
except requests.exceptions.RequestException as req_err:
|
| 159 |
-
logging.error(f"HTTP Request error for {symbol} from Google Finance: {req_err}")
|
| 160 |
-
except AttributeError:
|
| 161 |
-
logging.error(
|
| 162 |
-
f"Parsing error for {symbol} from Google Finance. "
|
| 163 |
-
"HTML structure might have changed."
|
| 164 |
-
)
|
| 165 |
-
except ValueError:
|
| 166 |
-
logging.error(
|
| 167 |
-
f"Data conversion error for {symbol} from Google Finance. "
|
| 168 |
-
"Could not convert price to float."
|
| 169 |
-
)
|
| 170 |
-
except Exception as e:
|
| 171 |
-
logging.error(f"Error fetching data for {symbol} from Google Finance: {e}")
|
| 172 |
-
return None
|
| 173 |
-
|
| 174 |
-
# ------------------------------------------------------------------
|
| 175 |
-
# Yahoo Finance
|
| 176 |
-
# ------------------------------------------------------------------
|
| 177 |
-
|
| 178 |
-
def fetch_symbol_yf(self, symbol: str) -> dict | None:
|
| 179 |
-
"""Scrape one symbol from Yahoo Finance. None on failure."""
|
| 180 |
-
yahoo_symbol = f'{symbol}.NS'
|
| 181 |
-
try:
|
| 182 |
-
response = self.session.get(
|
| 183 |
-
f'https://finance.yahoo.com/quote/{yahoo_symbol}',
|
| 184 |
-
timeout=HTTP_TIMEOUT_SEC,
|
| 185 |
-
)
|
| 186 |
-
response.raise_for_status()
|
| 187 |
-
soup = BeautifulSoup(response.text, 'html.parser')
|
| 188 |
-
|
| 189 |
-
best_res = None
|
| 190 |
-
for s in soup.find_all('script'):
|
| 191 |
-
if s.string and 'quoteResponse' in s.string:
|
| 192 |
-
try:
|
| 193 |
-
data = json.loads(s.string)
|
| 194 |
-
body = json.loads(data['body'])
|
| 195 |
-
for r_item in body['quoteResponse']['result']:
|
| 196 |
-
if r_item.get('symbol') == yahoo_symbol:
|
| 197 |
-
if best_res is None or (r_item.get('regularMarketDayHigh') is not None and best_res.get('regularMarketDayHigh') is None):
|
| 198 |
-
best_res = r_item
|
| 199 |
-
except Exception:
|
| 200 |
-
pass
|
| 201 |
-
|
| 202 |
-
if not best_res:
|
| 203 |
-
# Fallback to the old HTML tag parser (used by tests and old site structure)
|
| 204 |
-
try:
|
| 205 |
-
price_tag = soup.find(attrs={"data-testid": "qsp-price"})
|
| 206 |
-
if not price_tag:
|
| 207 |
-
price_tag = soup.find('fin-streamer', {'data-symbol': yahoo_symbol, 'data-field': 'regularMarketPrice'})
|
| 208 |
-
|
| 209 |
-
if not price_tag:
|
| 210 |
-
raise ValueError(f"No Svelte Sourced Quote data or HTML tags found for {symbol}")
|
| 211 |
-
|
| 212 |
-
current_price = float(price_tag.text.replace(',', ''))
|
| 213 |
-
|
| 214 |
-
prev_tag = soup.find('fin-streamer', {'data-symbol': yahoo_symbol, 'data-field': 'regularMarketPreviousClose'})
|
| 215 |
-
previous_close_price = float(prev_tag.text.replace(',', '')) if prev_tag else current_price
|
| 216 |
-
|
| 217 |
-
day_high, day_low = 0.0, 0.0
|
| 218 |
-
range_tag = soup.find('fin-streamer', {'data-symbol': yahoo_symbol, 'data-field': 'regularMarketDayRange'})
|
| 219 |
-
if range_tag and range_tag.text:
|
| 220 |
-
parts = [p.strip() for p in range_tag.text.split('-')]
|
| 221 |
-
if len(parts) == 2:
|
| 222 |
-
day_low = float(parts[0].replace(',', ''))
|
| 223 |
-
day_high = float(parts[1].replace(',', ''))
|
| 224 |
-
|
| 225 |
-
vol_tag = soup.find('fin-streamer', {'data-symbol': yahoo_symbol, 'data-field': 'regularMarketVolume'})
|
| 226 |
-
current_volume = float(vol_tag.text.replace(',', '')) if vol_tag else 0.0
|
| 227 |
-
except Exception as fallback_err:
|
| 228 |
-
raise ValueError(f"No Svelte Sourced Quote data found for {symbol} (HTML fallback failed: {fallback_err})")
|
| 229 |
-
else:
|
| 230 |
-
def get_val(field):
|
| 231 |
-
val = best_res.get(field)
|
| 232 |
-
if isinstance(val, dict):
|
| 233 |
-
return float(val.get('raw', 0.0))
|
| 234 |
-
return float(val) if val is not None else 0.0
|
| 235 |
-
|
| 236 |
-
current_price = get_val('regularMarketPrice')
|
| 237 |
-
if current_price <= 0.0:
|
| 238 |
-
raise ValueError(f"Invalid or zero price scraped for {symbol}")
|
| 239 |
-
previous_close_price = get_val('regularMarketPreviousClose')
|
| 240 |
-
day_high = get_val('regularMarketDayHigh')
|
| 241 |
-
day_low = get_val('regularMarketDayLow')
|
| 242 |
-
current_volume = get_val('regularMarketVolume')
|
| 243 |
-
previous_volume = 0.0
|
| 244 |
-
try:
|
| 245 |
-
previous_volume = float(self.prev_volume_lookup(symbol))
|
| 246 |
-
except Exception as e:
|
| 247 |
-
logging.error(f"Error fetching previous volume for {symbol}: {e}")
|
| 248 |
-
if previous_close_price == 0.0:
|
| 249 |
-
previous_close_price = current_price
|
| 250 |
-
return {
|
| 251 |
-
"SYMBOL": symbol,
|
| 252 |
-
"CLOSE_PRICE": current_price,
|
| 253 |
-
"PREV_CL_PR": previous_close_price,
|
| 254 |
-
"DAY_HIGH": day_high,
|
| 255 |
-
"DAY_LOW": day_low,
|
| 256 |
-
"CURR_VOL": current_volume,
|
| 257 |
-
"PREV_VOL": previous_volume,
|
| 258 |
-
}
|
| 259 |
-
except requests.exceptions.RequestException as req_err:
|
| 260 |
-
logging.error(f"HTTP Request error for {symbol} from Yahoo Finance: {req_err}")
|
| 261 |
-
except Exception as e:
|
| 262 |
-
logging.error(f"Error fetching data for {symbol} from Yahoo Finance: {e}")
|
| 263 |
-
return None
|
| 264 |
-
|
| 265 |
-
# Batch wrappers preserved for API parity with v4.5 ------------------
|
| 266 |
-
|
| 267 |
-
def fetch_real_time_data_gf(self, symbols: Iterable[str]) -> list[dict]:
|
| 268 |
-
return [d for d in (self.fetch_symbol_gf(s) for s in symbols) if d]
|
| 269 |
-
|
| 270 |
-
def fetch_real_time_data_yf(self, symbols: Iterable[str]) -> list[dict]:
|
| 271 |
-
return [d for d in (self.fetch_symbol_yf(s) for s in symbols) if d]
|
| 272 |
-
|
| 273 |
-
# ------------------------------------------------------------------
|
| 274 |
-
# Failover orchestration
|
| 275 |
-
# ------------------------------------------------------------------
|
| 276 |
-
|
| 277 |
-
def _fetch_one_with_failover(self, symbol: str,
|
| 278 |
-
preferred_source: str) -> tuple[dict | None, str]:
|
| 279 |
-
"""Fetch one symbol trying preferred source first, then the other."""
|
| 280 |
-
order = (
|
| 281 |
-
[("Google", self.fetch_symbol_gf), ("Yahoo", self.fetch_symbol_yf)]
|
| 282 |
-
if preferred_source == "Google"
|
| 283 |
-
else [("Yahoo", self.fetch_symbol_yf), ("Google", self.fetch_symbol_gf)]
|
| 284 |
-
)
|
| 285 |
-
primary_name = order[0][0]
|
| 286 |
-
for i, (name, fn) in enumerate(order):
|
| 287 |
-
data = fn(symbol)
|
| 288 |
-
if data:
|
| 289 |
-
return data, name
|
| 290 |
-
if i == 0:
|
| 291 |
-
logging.warning(
|
| 292 |
-
f"{primary_name} Finance failed for {symbol}. "
|
| 293 |
-
f"Falling back to {order[1][0]}."
|
| 294 |
-
)
|
| 295 |
-
logging.error(f"Both sources failed for {symbol}.")
|
| 296 |
-
return None, "None"
|
| 297 |
-
|
| 298 |
-
def fetch_real_time_data(self, symbols: list[str],
|
| 299 |
-
preferred_source: str = "Google"
|
| 300 |
-
) -> tuple[list[dict], str]:
|
| 301 |
-
"""
|
| 302 |
-
Fetch real-time data for all symbols with per-symbol failover.
|
| 303 |
-
Returns (list of quote dicts, source_name). Source name reflects
|
| 304 |
-
the last successful source, matching v4.5 semantics.
|
| 305 |
-
"""
|
| 306 |
-
preferred_source = preferred_source or "Google"
|
| 307 |
-
data_source = "None"
|
| 308 |
-
real_time_data: list[dict] = []
|
| 309 |
-
failed_symbols: list[str] = []
|
| 310 |
-
symbols = list(symbols)
|
| 311 |
-
if not symbols:
|
| 312 |
-
return [], data_source
|
| 313 |
-
|
| 314 |
-
with _global_fetch_lock:
|
| 315 |
-
with ThreadPoolExecutor(max_workers=min(FETCH_MAX_WORKERS, len(symbols))) as pool:
|
| 316 |
-
results = list(pool.map(
|
| 317 |
-
lambda s: (s, self._fetch_one_with_failover(s, preferred_source)),
|
| 318 |
-
symbols,
|
| 319 |
-
))
|
| 320 |
-
for symbol, (data, source) in results:
|
| 321 |
-
if data:
|
| 322 |
-
real_time_data.append(data)
|
| 323 |
-
data_source = source
|
| 324 |
-
else:
|
| 325 |
-
failed_symbols.append(symbol)
|
| 326 |
-
if failed_symbols:
|
| 327 |
-
logging.error(f"Failed symbols: {', '.join(failed_symbols)}")
|
| 328 |
-
return real_time_data, data_source
|
| 329 |
-
|
| 330 |
-
def fetch_index_quotes(self) -> dict[str, dict]:
|
| 331 |
-
"""
|
| 332 |
-
Scrape live market index quotes for NIFTY 50 (^NSEI) and SENSEX (^BSESN).
|
| 333 |
-
Returns dict keyed by index name ('NIFTY', 'SENSEX').
|
| 334 |
-
"""
|
| 335 |
-
indices = {
|
| 336 |
-
'NIFTY': '^NSEI',
|
| 337 |
-
'SENSEX': '^BSESN',
|
| 338 |
-
}
|
| 339 |
-
res = {}
|
| 340 |
-
for name, sym in indices.items():
|
| 341 |
-
try:
|
| 342 |
-
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1m"
|
| 343 |
-
r = self.session.get(url, timeout=HTTP_TIMEOUT_SEC)
|
| 344 |
-
r.raise_for_status()
|
| 345 |
-
data = r.json()
|
| 346 |
-
meta = data['chart']['result'][0]['meta']
|
| 347 |
-
price = float(meta.get('regularMarketPrice', 0.0))
|
| 348 |
-
prev = float(meta.get('chartPreviousClose') or meta.get('previousClose') or price)
|
| 349 |
-
chg = price - prev
|
| 350 |
-
pct = (chg / prev * 100.0) if prev > 0 else 0.0
|
| 351 |
-
res[name] = {
|
| 352 |
-
"name": name,
|
| 353 |
-
"price": round(price, 2),
|
| 354 |
-
"change": round(chg, 2),
|
| 355 |
-
"pct_change": round(pct, 2),
|
| 356 |
-
"formatted_price": f"{price:,.2f}",
|
| 357 |
-
"formatted_change": f"{chg:+,.2f} ({pct:+.2f}%)"
|
| 358 |
-
}
|
| 359 |
-
except Exception as e:
|
| 360 |
-
logging.warning(f"Could not fetch live index for {name} ({sym}): {e}")
|
| 361 |
-
return res
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/nvidia_llm.py
DELETED
|
@@ -1,933 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
NVIDIA NIM LLM Wrapper with Intelligent Routing
|
| 3 |
-
================================================
|
| 4 |
-
Maintains a curated registry of all 121 NVIDIA NIM free-tier models,
|
| 5 |
-
categorized and benchmarked by purpose. Provides intelligent routing
|
| 6 |
-
based on the request type (coding, reasoning, chat, memory, judge,
|
| 7 |
-
finance, vision, safety, translation, embedding).
|
| 8 |
-
|
| 9 |
-
Model Registry is stored in: utils/config/nvidia_model_registry.json
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from typing import Any, Optional
|
| 13 |
-
import requests
|
| 14 |
-
import os
|
| 15 |
-
import json
|
| 16 |
-
import threading
|
| 17 |
-
import datetime
|
| 18 |
-
import time
|
| 19 |
-
|
| 20 |
-
try:
|
| 21 |
-
from dotenv import load_dotenv, find_dotenv
|
| 22 |
-
env_file = find_dotenv(usecwd=True)
|
| 23 |
-
if env_file:
|
| 24 |
-
load_dotenv(env_file)
|
| 25 |
-
else:
|
| 26 |
-
load_dotenv()
|
| 27 |
-
except ImportError:
|
| 28 |
-
pass
|
| 29 |
-
|
| 30 |
-
try:
|
| 31 |
-
import keyring
|
| 32 |
-
except ImportError:
|
| 33 |
-
keyring = None
|
| 34 |
-
|
| 35 |
-
try:
|
| 36 |
-
from crewai.llms.base_llm import BaseLLM
|
| 37 |
-
except ImportError:
|
| 38 |
-
class BaseLLM:
|
| 39 |
-
def __init__(self, **kwargs):
|
| 40 |
-
self.model = kwargs.get("model")
|
| 41 |
-
self.api_key = kwargs.get("api_key")
|
| 42 |
-
|
| 43 |
-
# ---------------------------------------------------------------------------
|
| 44 |
-
# Telemetry Token Tracking State
|
| 45 |
-
# ---------------------------------------------------------------------------
|
| 46 |
-
TOKEN_STATE_FILE = os.path.join(os.path.dirname(__file__), "token_state.json")
|
| 47 |
-
MODEL_REGISTRY_FILE = os.path.join(os.path.dirname(__file__), "nvidia_model_registry.json")
|
| 48 |
-
token_lock = threading.Lock()
|
| 49 |
-
_WARNED_MISSING_KEY = False
|
| 50 |
-
|
| 51 |
-
def load_token_usage() -> dict:
|
| 52 |
-
with token_lock:
|
| 53 |
-
if os.path.exists(TOKEN_STATE_FILE):
|
| 54 |
-
try:
|
| 55 |
-
with open(TOKEN_STATE_FILE, "r") as f:
|
| 56 |
-
return json.load(f)
|
| 57 |
-
except Exception:
|
| 58 |
-
pass
|
| 59 |
-
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 60 |
-
|
| 61 |
-
def save_token_usage(usage: dict):
|
| 62 |
-
with token_lock:
|
| 63 |
-
try:
|
| 64 |
-
with open(TOKEN_STATE_FILE, "w") as f:
|
| 65 |
-
json.dump(usage, f, indent=4)
|
| 66 |
-
except Exception:
|
| 67 |
-
pass
|
| 68 |
-
|
| 69 |
-
def add_tokens(prompt: int, completion: int, model_id: str = ""):
|
| 70 |
-
usage = load_token_usage()
|
| 71 |
-
usage["prompt_tokens"] += prompt
|
| 72 |
-
usage["completion_tokens"] += completion
|
| 73 |
-
usage["total_tokens"] += (prompt + completion)
|
| 74 |
-
# Per-model telemetry
|
| 75 |
-
per_model = usage.setdefault("per_model", {})
|
| 76 |
-
entry = per_model.setdefault(model_id, {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
| 77 |
-
entry["prompt_tokens"] += prompt
|
| 78 |
-
entry["completion_tokens"] += completion
|
| 79 |
-
entry["total_tokens"] += (prompt + completion)
|
| 80 |
-
save_token_usage(usage)
|
| 81 |
-
|
| 82 |
-
def get_token_usage() -> dict:
|
| 83 |
-
return load_token_usage()
|
| 84 |
-
|
| 85 |
-
def reset_token_usage():
|
| 86 |
-
save_token_usage({"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0})
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
# ---------------------------------------------------------------------------
|
| 90 |
-
# Curated Model Registry — Built from deep research on 121 NVIDIA NIM models
|
| 91 |
-
# ---------------------------------------------------------------------------
|
| 92 |
-
# Categories: coding, reasoning, chat, memory, judge, finance, vision,
|
| 93 |
-
# safety, translation, embedding, creative, fast
|
| 94 |
-
#
|
| 95 |
-
# Ratings: 5=exceptional, 4=excellent, 3=good, 2=decent, 1=basic
|
| 96 |
-
# Source: NVIDIA NIM catalog + community benchmarks (HumanEval, MMLU, etc.)
|
| 97 |
-
# ---------------------------------------------------------------------------
|
| 98 |
-
|
| 99 |
-
NVIDIA_MODEL_REGISTRY = {
|
| 100 |
-
# === CODING ===
|
| 101 |
-
# Best for: code generation, debugging, refactoring, code review, self-improvement agents
|
| 102 |
-
"coding": {
|
| 103 |
-
"primary": "deepseek-ai/deepseek-v4-pro",
|
| 104 |
-
"fallback": ["deepseek-ai/deepseek-v4-flash", "poolside/laguna-xs-2.1", "abacusai/dracarys-llama-3.1-70b-instruct", "nvidia/nv-embedcode-7b-v1"],
|
| 105 |
-
"models": {
|
| 106 |
-
"deepseek-ai/deepseek-v4-pro": {
|
| 107 |
-
"rating": 5, "context_window": 163840, "description": "State-of-the-art coder. Outstanding performance on code generation and multi-step agent debugging.", "strengths": ["code_gen", "agentic", "debugging"]
|
| 108 |
-
},
|
| 109 |
-
"deepseek-ai/deepseek-v4-flash": {
|
| 110 |
-
"rating": 4, "context_window": 163840, "description": "High-throughput coding model. Ideal for low-latency coding loops.", "strengths": ["code_gen", "fast", "agentic"]
|
| 111 |
-
},
|
| 112 |
-
"poolside/laguna-xs-2.1": {
|
| 113 |
-
"rating": 5, "context_window": 32768, "description": "Poolside Laguna XS. Specially trained for agentic coding and long-horizon developer workflows.", "strengths": ["code_gen", "agentic", "planning"]
|
| 114 |
-
},
|
| 115 |
-
"abacusai/dracarys-llama-3.1-70b-instruct": {
|
| 116 |
-
"rating": 4, "context_window": 131072, "description": "Abacus.AI fine-tune of Llama 3.1 70B, optimized for complex code instruction following.", "strengths": ["code_gen", "instruction"]
|
| 117 |
-
},
|
| 118 |
-
"nvidia/nv-embedcode-7b-v1": {
|
| 119 |
-
"rating": 3, "context_window": 8192, "description": "NVIDIA specialized coding embedding model for semantic code search.", "strengths": ["embedding", "code_search"]
|
| 120 |
-
}
|
| 121 |
-
}
|
| 122 |
-
},
|
| 123 |
-
|
| 124 |
-
# === REASONING / COMPLEX TASKS ===
|
| 125 |
-
# Best for: multi-step reasoning, mathematical analysis, strategy planning, agentic workflows
|
| 126 |
-
"reasoning": {
|
| 127 |
-
"primary": "google/diffusiongemma-26b-a4b-it",
|
| 128 |
-
"fallback": ["meta/llama-3.1-70b-instruct", "minimaxai/minimax-m3", "z-ai/glm-5.2", "nvidia/nemotron-3-ultra-550b-a55b", "thinkingmachines/inkling", "meta/llama-3.3-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5", "meta/llama-3.1-8b-instruct"],
|
| 129 |
-
"models": {
|
| 130 |
-
"minimaxai/minimax-m3": {
|
| 131 |
-
"rating": 5, "context_window": 128000, "description": "MiniMax-M3 SOTA reasoning model. Exceptional financial analysis and long context planning.", "strengths": ["reasoning", "finance", "instruction"]
|
| 132 |
-
},
|
| 133 |
-
"google/diffusiongemma-26b-a4b-it": {
|
| 134 |
-
"rating": 5, "context_window": 128000, "description": "Google DiffusionGemma 26B A4B IT. Next-gen reasoning and fast inference.", "strengths": ["reasoning", "finance", "agentic"]
|
| 135 |
-
},
|
| 136 |
-
"z-ai/glm-5.2": {
|
| 137 |
-
"rating": 5, "context_window": 128000, "description": "GLM-5.2 SOTA reasoning model. Outstanding multi-step planning and financial instruction following.", "strengths": ["reasoning", "finance", "instruction"]
|
| 138 |
-
},
|
| 139 |
-
"nvidia/nemotron-3-ultra-550b-a55b": {
|
| 140 |
-
"rating": 5, "context_window": 128000, "description": "NVIDIA 550B reasoning model. Exceptional logical analysis.", "strengths": ["reasoning", "agentic", "math"]
|
| 141 |
-
},
|
| 142 |
-
"thinkingmachines/inkling": {
|
| 143 |
-
"rating": 4, "context_window": 32768, "description": "Inkling 975B MoE reasoning model. Excels at complex analysis.", "strengths": ["reasoning", "moe"]
|
| 144 |
-
},
|
| 145 |
-
"meta/llama-3.1-70b-instruct": {
|
| 146 |
-
"rating": 5, "context_window": 128000, "description": "Meta Llama 3.1 70B Instruct. High throughput, low latency.", "strengths": ["reasoning", "finance", "fast"]
|
| 147 |
-
},
|
| 148 |
-
"meta/llama-3.3-70b-instruct": {
|
| 149 |
-
"rating": 5, "context_window": 128000, "description": "Meta Llama 3.3 70B Instruct.", "strengths": ["reasoning", "finance"]
|
| 150 |
-
},
|
| 151 |
-
"nvidia/llama-3.3-nemotron-super-49b-v1.5": {
|
| 152 |
-
"rating": 4, "context_window": 128000, "description": "NVIDIA Nemotron Super 49B v1.5.", "strengths": ["reasoning", "agentic"]
|
| 153 |
-
},
|
| 154 |
-
"meta/llama-3.1-8b-instruct": {
|
| 155 |
-
"rating": 4, "context_window": 128000, "description": "Llama 3.1 8B Instruct. Fast fallback.", "strengths": ["fast", "general"]
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
},
|
| 159 |
-
|
| 160 |
-
# === CHAT / GENERAL PURPOSE ===
|
| 161 |
-
# Best for: conversational Q&A, summarization, instruction-following
|
| 162 |
-
"chat": {
|
| 163 |
-
"primary": "meta/llama-3.3-70b-instruct",
|
| 164 |
-
"fallback": ["meta/llama-3.1-70b-instruct", "meta/llama-3.1-8b-instruct", "minimaxai/minimax-m3", "mistralai/mistral-medium-3.5-128b", "google/gemma-4-31b-it", "mistralai/mistral-small-4-119b-2603", "qwen/qwen3.5-122b-a10b", "nvidia/nemotron-3-nano-30b-a3b", "mistralai/ministral-14b-instruct-2512", "bytedance/seed-oss-36b-instruct", "nvidia/nvidia-nemotron-nano-9b-v2", "openai/gpt-oss-20b", "openai/gpt-oss-120b", "sarvamai/sarvam-m", "google/gemma-3n-e4b-it", "google/gemma-3n-e2b-it", "mistralai/mistral-nemotron", "nvidia/llama-3.1-nemotron-nano-8b-v1", "upstage/solar-10.7b-instruct", "mistralai/mixtral-8x7b-instruct-v0.1", "minimaxai/minimax-m2.7", "stepfun-ai/step-3.7-flash", "stepfun-ai/step-3.5-flash"],
|
| 165 |
-
"models": {
|
| 166 |
-
"meta/llama-3.3-70b-instruct": {
|
| 167 |
-
"rating": 5, "context_window": 128000, "description": "Meta Llama 3.3 70B. High-quality instruction following, default conversational model.", "strengths": ["chat", "general", "instruction"]
|
| 168 |
-
},
|
| 169 |
-
"meta/llama-3.1-70b-instruct": {
|
| 170 |
-
"rating": 4, "context_window": 128000, "description": "Meta Llama 3.1 70B. Excellent instruction execution and formatting.", "strengths": ["chat", "general"]
|
| 171 |
-
},
|
| 172 |
-
"meta/llama-3.1-8b-instruct": {
|
| 173 |
-
"rating": 4, "context_window": 128000, "description": "Llama 3.1 8B. Fast, reliable conversational chat model.", "strengths": ["chat", "fast", "general"]
|
| 174 |
-
},
|
| 175 |
-
"minimaxai/minimax-m3": {
|
| 176 |
-
"rating": 4, "context_window": 40960, "description": "MiniMax M3. Strong general assistant, optimized for long-form dialogue.", "strengths": ["chat", "dialogue"]
|
| 177 |
-
},
|
| 178 |
-
"minimaxai/minimax-m2.7": {
|
| 179 |
-
"rating": 4, "context_window": 40960, "description": "MiniMax M2.7. Fast conversational LLM.", "strengths": ["chat", "fast"]
|
| 180 |
-
},
|
| 181 |
-
"stepfun-ai/step-3.7-flash": {
|
| 182 |
-
"rating": 4, "context_window": 128000, "description": "StepFun 3.7 Flash. Ultra-fast high-concurrency conversational assistant.", "strengths": ["chat", "fast"]
|
| 183 |
-
},
|
| 184 |
-
"stepfun-ai/step-3.5-flash": {
|
| 185 |
-
"rating": 3, "context_window": 128000, "description": "StepFun 3.5 Flash model.", "strengths": ["chat", "fast"]
|
| 186 |
-
},
|
| 187 |
-
"mistralai/mistral-medium-3.5-128b": {
|
| 188 |
-
"rating": 4, "context_window": 128000, "description": "Mistral Medium 3.5. Balanced instruct model with great reasoning capacity.", "strengths": ["chat", "instruction"]
|
| 189 |
-
},
|
| 190 |
-
"google/gemma-4-31b-it": {
|
| 191 |
-
"rating": 4, "context_window": 128000, "description": "Google Gemma 4 31B instruction-tuned model. Top scores in general chat.", "strengths": ["chat", "instruction"]
|
| 192 |
-
},
|
| 193 |
-
"mistralai/mistral-small-4-119b-2603": {
|
| 194 |
-
"rating": 4, "context_window": 32768, "description": "Mistral Small 4 119B. High quality intermediate model for general instruct tasks.", "strengths": ["chat", "instruction"]
|
| 195 |
-
},
|
| 196 |
-
"qwen/qwen3.5-122b-a10b": {
|
| 197 |
-
"rating": 4, "context_window": 32768, "description": "Qwen3.5 122B MoE model, highly conversational.", "strengths": ["chat", "moe"]
|
| 198 |
-
},
|
| 199 |
-
"nvidia/nemotron-3-nano-30b-a3b": {
|
| 200 |
-
"rating": 3, "context_window": 32768, "description": "NVIDIA Nemotron 3 Nano.", "strengths": ["chat", "fast"]
|
| 201 |
-
},
|
| 202 |
-
"mistralai/ministral-14b-instruct-2512": {
|
| 203 |
-
"rating": 3, "context_window": 32768, "description": "Ministral 14B instruction model.", "strengths": ["chat", "fast"]
|
| 204 |
-
},
|
| 205 |
-
"bytedance/seed-oss-36b-instruct": {
|
| 206 |
-
"rating": 4, "context_window": 32768, "description": "ByteDance Seed OSS 36B.", "strengths": ["chat", "general"]
|
| 207 |
-
},
|
| 208 |
-
"nvidia/nvidia-nemotron-nano-9b-v2": {
|
| 209 |
-
"rating": 3, "context_window": 32768, "description": "NVIDIA Nemotron Nano 9B v2.", "strengths": ["chat", "fast"]
|
| 210 |
-
},
|
| 211 |
-
"openai/gpt-oss-20b": {
|
| 212 |
-
"rating": 4, "context_window": 32768, "description": "OpenAI GPT-OSS 20B model.", "strengths": ["chat", "general"]
|
| 213 |
-
},
|
| 214 |
-
"openai/gpt-oss-120b": {
|
| 215 |
-
"rating": 4, "context_window": 32768, "description": "OpenAI GPT-OSS 120B model.", "strengths": ["chat", "general"]
|
| 216 |
-
},
|
| 217 |
-
"sarvamai/sarvam-m": {
|
| 218 |
-
"rating": 3, "context_window": 8192, "description": "Sarvam-M multilingual chat model.", "strengths": ["chat", "multilingual"]
|
| 219 |
-
},
|
| 220 |
-
"google/gemma-3n-e4b-it": {
|
| 221 |
-
"rating": 4, "context_window": 128000, "description": "Google Gemma 3N E4B model.", "strengths": ["chat", "general"]
|
| 222 |
-
},
|
| 223 |
-
"google/gemma-3n-e2b-it": {
|
| 224 |
-
"rating": 4, "context_window": 128000, "description": "Google Gemma 3N E2B model.", "strengths": ["chat", "general"]
|
| 225 |
-
},
|
| 226 |
-
"mistralai/mistral-nemotron": {
|
| 227 |
-
"rating": 4, "context_window": 128000, "description": "Mistral-Nemotron collaboration model.", "strengths": ["chat", "general"]
|
| 228 |
-
},
|
| 229 |
-
"nvidia/llama-3.1-nemotron-nano-8b-v1": {
|
| 230 |
-
"rating": 3, "context_window": 128000, "description": "NVIDIA tuned Llama 3.1 Nano 8B.", "strengths": ["chat", "fast"]
|
| 231 |
-
},
|
| 232 |
-
"upstage/solar-10.7b-instruct": {
|
| 233 |
-
"rating": 3, "context_window": 4096, "description": "Upstage Solar 10.7B instruction-tuned model.", "strengths": ["chat", "instruction"]
|
| 234 |
-
},
|
| 235 |
-
"mistralai/mixtral-8x7b-instruct-v0.1": {
|
| 236 |
-
"rating": 3, "context_window": 32768, "description": "Mixtral 8x7B MoE model.", "strengths": ["chat", "moe"]
|
| 237 |
-
}
|
| 238 |
-
}
|
| 239 |
-
},
|
| 240 |
-
|
| 241 |
-
# === MEMORY / SUMMARIZATION ===
|
| 242 |
-
# Best for: MEMORY.md updates, long-context summarization, KB compression
|
| 243 |
-
"memory": {
|
| 244 |
-
"primary": "meta/llama-4-maverick-17b-128e-instruct",
|
| 245 |
-
"fallback": ["moonshotai/kimi-k2.6"],
|
| 246 |
-
"models": {
|
| 247 |
-
"meta/llama-4-maverick-17b-128e-instruct": {
|
| 248 |
-
"rating": 5, "context_window": 1000000, "description": "Llama 4 Maverick. 1M token context, perfect for large codebase digests.", "strengths": ["summarization", "long_context", "memory"]
|
| 249 |
-
},
|
| 250 |
-
"moonshotai/kimi-k2.6": {
|
| 251 |
-
"rating": 4, "context_window": 131072, "description": "Kimi K2.6. 128K context window, highly accurate long-document analysis.", "strengths": ["summarization", "long_context"]
|
| 252 |
-
}
|
| 253 |
-
}
|
| 254 |
-
},
|
| 255 |
-
|
| 256 |
-
# === SAFETY / CONTENT MODERATION ===
|
| 257 |
-
# Best for: guardrails, PII detection, content safety checks, topic control
|
| 258 |
-
"safety": {
|
| 259 |
-
"primary": "nvidia/nemotron-3.5-content-safety",
|
| 260 |
-
"fallback": ["meta/llama-guard-4-12b", "nvidia/gliner-pii", "nvidia/llama-3.1-nemotron-safety-guard-8b-v3"],
|
| 261 |
-
"models": {
|
| 262 |
-
"nvidia/nemotron-3.5-content-safety": {
|
| 263 |
-
"rating": 5, "context_window": 4096, "description": "NVIDIA specialized content safety model.", "strengths": ["safety", "moderation"]
|
| 264 |
-
},
|
| 265 |
-
"meta/llama-guard-4-12b": {
|
| 266 |
-
"rating": 4, "context_window": 128000, "description": "Llama Guard 4 12B safety classifier.", "strengths": ["safety", "moderation"]
|
| 267 |
-
},
|
| 268 |
-
"nvidia/gliner-pii": {
|
| 269 |
-
"rating": 4, "context_window": 8192, "description": "GLiNER PII recognition model for anonymization.", "strengths": ["safety", "pii_redaction"]
|
| 270 |
-
},
|
| 271 |
-
"nvidia/llama-3.1-nemotron-safety-guard-8b-v3": {
|
| 272 |
-
"rating": 4, "context_window": 8192, "description": "NVIDIA safety guard 8B v3.", "strengths": ["safety", "moderation"]
|
| 273 |
-
}
|
| 274 |
-
}
|
| 275 |
-
},
|
| 276 |
-
|
| 277 |
-
# === TRANSLATION ===
|
| 278 |
-
"translation": {
|
| 279 |
-
"primary": "nvidia/riva-translate-4b-instruct-v1.1",
|
| 280 |
-
"models": {
|
| 281 |
-
"nvidia/riva-translate-4b-instruct-v1.1": {
|
| 282 |
-
"rating": 4, "context_window": 4096, "description": "Riva machine translation model.", "strengths": ["translation"]
|
| 283 |
-
}
|
| 284 |
-
}
|
| 285 |
-
},
|
| 286 |
-
|
| 287 |
-
# === EMBEDDING ===
|
| 288 |
-
# Best for: vector embeddings for semantic search, RAG, document retrieval
|
| 289 |
-
"embedding": {
|
| 290 |
-
"primary": "nvidia/nv-embedcode-7b-v1",
|
| 291 |
-
"fallback": ["nvidia/nemotron-3-embed-1b", "nvidia/rerank-qa-mistral-4b"],
|
| 292 |
-
"models": {
|
| 293 |
-
"nvidia/nv-embedcode-7b-v1": {
|
| 294 |
-
"rating": 4, "context_window": 8192, "description": "NVIDIA specialized code embedding model.", "strengths": ["embedding", "code_search"]
|
| 295 |
-
},
|
| 296 |
-
"nvidia/nemotron-3-embed-1b": {
|
| 297 |
-
"rating": 3, "context_window": 512, "description": "Nemotron-3 1B embedding model.", "strengths": ["embedding"]
|
| 298 |
-
},
|
| 299 |
-
"nvidia/rerank-qa-mistral-4b": {
|
| 300 |
-
"rating": 4, "context_window": 4096, "description": "Mistral-based QA reranker model.", "strengths": ["rerank", "rag"]
|
| 301 |
-
}
|
| 302 |
-
}
|
| 303 |
-
},
|
| 304 |
-
|
| 305 |
-
# === VISION / MULTIMODAL ===
|
| 306 |
-
# Best for: image understanding, chart analysis, VLM-based tasks
|
| 307 |
-
"vision": {
|
| 308 |
-
"primary": "meta/llama-3.2-90b-vision-instruct",
|
| 309 |
-
"fallback": ["meta/llama-3.2-11b-vision-instruct", "google/diffusiongemma-26b-a4b-it", "google/paligemma", "nvidia/nemotron-nano-12b-v2-vl", "nvidia/llama-3.1-nemotron-nano-vl-8b-v1"],
|
| 310 |
-
"models": {
|
| 311 |
-
"meta/llama-3.2-90b-vision-instruct": {
|
| 312 |
-
"rating": 5, "context_window": 128000, "description": "Meta Llama 3.2 90B Vision. High-end image and chart understanding.", "strengths": ["vision", "chart_analysis"]
|
| 313 |
-
},
|
| 314 |
-
"meta/llama-3.2-11b-vision-instruct": {
|
| 315 |
-
"rating": 4, "context_window": 128000, "description": "Meta Llama 3.2 11B Vision. Efficient image captioning and Q&A.", "strengths": ["vision", "fast"]
|
| 316 |
-
},
|
| 317 |
-
"google/diffusiongemma-26b-a4b-it": {
|
| 318 |
-
"rating": 4, "context_window": 8192, "description": "DiffusionGemma 26B multimodal visual instruction model.", "strengths": ["vision", "diffusion"]
|
| 319 |
-
},
|
| 320 |
-
"google/paligemma": {
|
| 321 |
-
"rating": 3, "context_window": 4096, "description": "Google PaliGemma multimodal model.", "strengths": ["vision", "vqa"]
|
| 322 |
-
},
|
| 323 |
-
"nvidia/nemotron-nano-12b-v2-vl": {
|
| 324 |
-
"rating": 4, "context_window": 32768, "description": "NVIDIA Nemotron Nano 12B VL model.", "strengths": ["vision", "vl"]
|
| 325 |
-
},
|
| 326 |
-
"nvidia/llama-3.1-nemotron-nano-vl-8b-v1": {
|
| 327 |
-
"rating": 3, "context_window": 128000, "description": "NVIDIA tuned Llama 3.1 Nano VL 8B.", "strengths": ["vision", "fast"]
|
| 328 |
-
}
|
| 329 |
-
}
|
| 330 |
-
},
|
| 331 |
-
|
| 332 |
-
# === FAST / LIGHTWEIGHT ===
|
| 333 |
-
# Best for: quick classification, token-budget-conscious tasks, high-throughput pipelines
|
| 334 |
-
"fast": {
|
| 335 |
-
"primary": "meta/llama-3.2-3b-instruct",
|
| 336 |
-
"fallback": ["meta/llama-3.2-1b-instruct", "nvidia/nemotron-mini-4b-instruct", "google/gemma-2-2b-it"],
|
| 337 |
-
"models": {
|
| 338 |
-
"meta/llama-3.2-3b-instruct": {
|
| 339 |
-
"rating": 4, "context_window": 128000, "description": "Llama 3.2 3B. Fast, lightweight edge-tier chatbot.", "strengths": ["fast", "lightweight"]
|
| 340 |
-
},
|
| 341 |
-
"meta/llama-3.2-1b-instruct": {
|
| 342 |
-
"rating": 3, "context_window": 128000, "description": "Llama 3.2 1B. Compact model for low token footprint.", "strengths": ["fast", "lightweight"]
|
| 343 |
-
},
|
| 344 |
-
"nvidia/nemotron-mini-4b-instruct": {
|
| 345 |
-
"rating": 3, "context_window": 4096, "description": "Nemotron Mini 4B instruction model.", "strengths": ["fast", "compact"]
|
| 346 |
-
},
|
| 347 |
-
"google/gemma-2-2b-it": {
|
| 348 |
-
"rating": 3, "context_window": 8192, "description": "Google Gemma-2 2B instruct.", "strengths": ["fast", "compact"]
|
| 349 |
-
}
|
| 350 |
-
}
|
| 351 |
-
},
|
| 352 |
-
|
| 353 |
-
# === SCIENCE & SPECIALIZED ===
|
| 354 |
-
"science": {
|
| 355 |
-
"primary": "nvidia/esmfold",
|
| 356 |
-
"fallback": ["nvidia/esm2-650m"],
|
| 357 |
-
"models": {
|
| 358 |
-
"nvidia/esmfold": {
|
| 359 |
-
"rating": 5, "context_window": 1024, "description": "ESMFold model for direct protein structure prediction from sequence.", "strengths": ["science", "protein_folding"]
|
| 360 |
-
},
|
| 361 |
-
"nvidia/esm2-650m": {
|
| 362 |
-
"rating": 4, "context_window": 1024, "description": "ESM-2 650M parameter protein language model.", "strengths": ["science", "protein_language"]
|
| 363 |
-
}
|
| 364 |
-
}
|
| 365 |
-
},
|
| 366 |
-
|
| 367 |
-
# === MULTIMODAL VISION / VEHICLES ===
|
| 368 |
-
"multimodal_vision": {
|
| 369 |
-
"primary": "nvidia/cosmos3-nano",
|
| 370 |
-
"fallback": ["nvidia/cosmos3-nano-reasoner", "nvidia/ai-synthetic-video-detector", "nvidia/active-speaker-detection", "nvidia/ising-calibration-1-35b-a3b", "nvidia/cosmos-transfer2.5-2b", "nvidia/streampetr", "nvidia/cosmos-transfer1-7b", "nvidia/sparsedrive", "nvidia/bevformer"],
|
| 371 |
-
"models": {
|
| 372 |
-
"nvidia/cosmos3-nano": {
|
| 373 |
-
"rating": 5, "context_window": 8192, "description": "Cosmos-3 Nano spatial video model.", "strengths": ["video", "spatial"]
|
| 374 |
-
},
|
| 375 |
-
"nvidia/cosmos3-nano-reasoner": {
|
| 376 |
-
"rating": 5, "context_window": 8192, "description": "Cosmos-3 Nano reasoning visual agent.", "strengths": ["video", "reasoning"]
|
| 377 |
-
},
|
| 378 |
-
"nvidia/ai-synthetic-video-detector": {
|
| 379 |
-
"rating": 4, "context_window": 4096, "description": "Synthetic video generation detector.", "strengths": ["video", "detection"]
|
| 380 |
-
},
|
| 381 |
-
"nvidia/active-speaker-detection": {
|
| 382 |
-
"rating": 4, "context_window": 4096, "description": "Maxine active speaker detection.", "strengths": ["video", "maxine"]
|
| 383 |
-
},
|
| 384 |
-
"nvidia/ising-calibration-1-35b-a3b": {
|
| 385 |
-
"rating": 3, "context_window": 4096, "description": "Ising calibration vision model.", "strengths": ["video"]
|
| 386 |
-
},
|
| 387 |
-
"nvidia/cosmos-transfer2.5-2b": {
|
| 388 |
-
"rating": 4, "context_window": 4096, "description": "Cosmos Transfer 2.5 2B video diffusion helper.", "strengths": ["video"]
|
| 389 |
-
},
|
| 390 |
-
"nvidia/streampetr": {
|
| 391 |
-
"rating": 4, "context_window": 4096, "description": "StreamPETR 3D object detection for autonomous driving.", "strengths": ["driving", "3d_detection"]
|
| 392 |
-
},
|
| 393 |
-
"nvidia/cosmos-transfer1-7b": {
|
| 394 |
-
"rating": 4, "context_window": 4096, "description": "Cosmos Transfer 1 7B video model.", "strengths": ["video"]
|
| 395 |
-
},
|
| 396 |
-
"nvidia/sparsedrive": {
|
| 397 |
-
"rating": 4, "context_window": 4096, "description": "SparseDrive planning for autonomous vehicles.", "strengths": ["driving"]
|
| 398 |
-
},
|
| 399 |
-
"nvidia/bevformer": {
|
| 400 |
-
"rating": 4, "context_window": 4096, "description": "BEVFormer bird's-eye-view camera perception.", "strengths": ["driving", "perception"]
|
| 401 |
-
}
|
| 402 |
-
}
|
| 403 |
-
},
|
| 404 |
-
|
| 405 |
-
# === AUDIO / SPEECH ===
|
| 406 |
-
"audio": {
|
| 407 |
-
"primary": "nvidia/nemotron-voicechat",
|
| 408 |
-
"fallback": ["nvidia/magpie-tts-zeroshot", "nvidia/background-noise-removal", "nvidia/studio-voice-enhance"],
|
| 409 |
-
"models": {
|
| 410 |
-
"nvidia/nemotron-voicechat": {
|
| 411 |
-
"rating": 5, "context_window": 4096, "description": "Nemotron model optimized for voice-based conversational turn-taking.", "strengths": ["voice", "chat"]
|
| 412 |
-
},
|
| 413 |
-
"nvidia/magpie-tts-zeroshot": {
|
| 414 |
-
"rating": 4, "context_window": 4096, "description": "Magpie zero-shot Text-to-Speech synthesis.", "strengths": ["voice", "tts"]
|
| 415 |
-
},
|
| 416 |
-
"nvidia/background-noise-removal": {
|
| 417 |
-
"rating": 4, "context_window": 4096, "description": "Maxine background noise removal audio NIM.", "strengths": ["audio", "maxine"]
|
| 418 |
-
},
|
| 419 |
-
"nvidia/studio-voice-enhance": {
|
| 420 |
-
"rating": 4, "context_window": 4096, "description": "Maxine Studio voice enhance audio NIM.", "strengths": ["audio", "maxine"]
|
| 421 |
-
}
|
| 422 |
-
}
|
| 423 |
-
}
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
# Keyword → category routing table for automatic dispatch
|
| 427 |
-
ROUTING_KEYWORDS = {
|
| 428 |
-
"coding": ["code", "python", "function", "class", "debug", "refactor", "implement", "syntax", "algorithm", "programming", "script", "module", "bug", "fix", "rewrite", "agent_workflow", "agent"],
|
| 429 |
-
"reasoning": ["reason", "analyze", "plan", "strategy", "optimize", "complex", "math", "calculate", "evaluate", "multi-step", "explain", "compare", "decide", "judge", "score", "audit", "rate", "hallucin", "assess", "review trajectory", "llm-as-a-judge", "finance", "stock", "trading", "market", "portfolio", "financial", "investment", "equity", "backtest", "capital"],
|
| 430 |
-
"memory": ["summarize", "memory", "kb", "knowledge", "log", "history", "update memory", "compress", "recall", "persist"],
|
| 431 |
-
"chat": ["chat", "write", "creative", "document", "report", "essay", "story", "generate text"],
|
| 432 |
-
"safety": ["safety", "harmful", "pii", "content moderation", "guardrail", "toxic", "filter", "censor"],
|
| 433 |
-
"fast": ["quick", "fast", "brief", "short", "classify", "yes/no", "simple", "ping"],
|
| 434 |
-
"science": ["science", "protein", "esmfold", "esm2", "biology", "molecule", "structure", "medical", "health", "clinical", "patient", "diagnosis", "symptom", "drug"],
|
| 435 |
-
"vision": ["image", "chart", "graph", "visual", "picture", "plot", "screenshot", "vision"],
|
| 436 |
-
"multimodal_vision": ["cosmos", "video", "driving", "bevformer", "sparsedrive", "calibration"],
|
| 437 |
-
"audio": ["voice", "tts", "speech", "noise", "audio", "sound", "enhance"],
|
| 438 |
-
"translation": ["translate", "riva", "language", "bilingual", "multilingual"],
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
def route_request(prompt: str, hint: Optional[str] = None) -> tuple[str, str]:
|
| 443 |
-
"""
|
| 444 |
-
Determine the best category and model for a given prompt.
|
| 445 |
-
Returns (category, model_id).
|
| 446 |
-
- hint: explicit category override (e.g. 'coding', 'judge', 'memory')
|
| 447 |
-
"""
|
| 448 |
-
if hint and hint in NVIDIA_MODEL_REGISTRY:
|
| 449 |
-
cat = hint
|
| 450 |
-
return cat, NVIDIA_MODEL_REGISTRY[cat]["primary"]
|
| 451 |
-
|
| 452 |
-
prompt_lower = prompt.lower()
|
| 453 |
-
scores = {}
|
| 454 |
-
for category, keywords in ROUTING_KEYWORDS.items():
|
| 455 |
-
score = sum(1 for kw in keywords if kw in prompt_lower)
|
| 456 |
-
if score > 0:
|
| 457 |
-
scores[category] = score
|
| 458 |
-
|
| 459 |
-
if scores:
|
| 460 |
-
best_cat = max(scores, key=scores.get)
|
| 461 |
-
return best_cat, NVIDIA_MODEL_REGISTRY[best_cat]["primary"]
|
| 462 |
-
|
| 463 |
-
# Default: fast general chat
|
| 464 |
-
return "chat", NVIDIA_MODEL_REGISTRY["chat"]["primary"]
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
def get_model_registry() -> dict:
|
| 468 |
-
"""Return the full model registry dict."""
|
| 469 |
-
return NVIDIA_MODEL_REGISTRY
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
def get_best_model(category: str) -> str:
|
| 473 |
-
"""Return the primary model ID for a given category."""
|
| 474 |
-
# Handle remapped/deprecated categories gracefully
|
| 475 |
-
category_map = {
|
| 476 |
-
"judge": "reasoning",
|
| 477 |
-
"finance": "reasoning",
|
| 478 |
-
"creative": "chat",
|
| 479 |
-
"medical": "science"
|
| 480 |
-
}
|
| 481 |
-
mapped_cat = category_map.get(category, category)
|
| 482 |
-
entry = NVIDIA_MODEL_REGISTRY.get(mapped_cat)
|
| 483 |
-
if entry:
|
| 484 |
-
return entry["primary"]
|
| 485 |
-
return "meta/llama-3.1-8b-instruct"
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
def list_categories() -> list:
|
| 489 |
-
"""Return all routing categories."""
|
| 490 |
-
return list(NVIDIA_MODEL_REGISTRY.keys())
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
def save_model_registry():
|
| 494 |
-
"""Persist registry to JSON for external inspection / KB."""
|
| 495 |
-
os.makedirs(os.path.dirname(MODEL_REGISTRY_FILE), exist_ok=True)
|
| 496 |
-
try:
|
| 497 |
-
with open(MODEL_REGISTRY_FILE, "w") as f:
|
| 498 |
-
json.dump({
|
| 499 |
-
"updated_at": datetime.datetime.now().isoformat(),
|
| 500 |
-
"total_nvidia_nim_models": 74,
|
| 501 |
-
"categories": list(NVIDIA_MODEL_REGISTRY.keys()),
|
| 502 |
-
"registry": NVIDIA_MODEL_REGISTRY,
|
| 503 |
-
"routing_keywords": ROUTING_KEYWORDS,
|
| 504 |
-
}, f, indent=2)
|
| 505 |
-
print(f"[NVIDIA LLM] Model registry saved to {MODEL_REGISTRY_FILE}")
|
| 506 |
-
except Exception as e:
|
| 507 |
-
print(f"[NVIDIA LLM] Failed to save registry: {e}")
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
# ---------------------------------------------------------------------------
|
| 511 |
-
# Keyring resolution and API status testing
|
| 512 |
-
# ---------------------------------------------------------------------------
|
| 513 |
-
_NVIDIA_API_STATUS = {
|
| 514 |
-
"tested": False,
|
| 515 |
-
"success": False,
|
| 516 |
-
"message": "Not initialized",
|
| 517 |
-
"source": "None"
|
| 518 |
-
}
|
| 519 |
-
|
| 520 |
-
def resolve_nvidia_api_key() -> tuple[str | None, str]:
|
| 521 |
-
"""
|
| 522 |
-
Fetch NVIDIA API Key from environment variable, .env files, or system keyring.
|
| 523 |
-
Returns (api_key, source_description).
|
| 524 |
-
"""
|
| 525 |
-
# 1. Environment variable
|
| 526 |
-
key = os.getenv("NVIDIA_API_KEY") or os.getenv("NVIDIA_NIM_API_KEY")
|
| 527 |
-
if key:
|
| 528 |
-
clean_key = str(key).replace("\n", "").replace("\r", "").strip()
|
| 529 |
-
if clean_key:
|
| 530 |
-
return clean_key, "Environment Variable"
|
| 531 |
-
|
| 532 |
-
# 2. Check dotenv explicitly
|
| 533 |
-
try:
|
| 534 |
-
from dotenv import find_dotenv, dotenv_values
|
| 535 |
-
found = find_dotenv(usecwd=True)
|
| 536 |
-
if found:
|
| 537 |
-
vals = dotenv_values(found)
|
| 538 |
-
key = vals.get("NVIDIA_API_KEY") or vals.get("NVIDIA_NIM_API_KEY")
|
| 539 |
-
if key:
|
| 540 |
-
clean_key = str(key).replace("\n", "").replace("\r", "").strip()
|
| 541 |
-
if clean_key:
|
| 542 |
-
os.environ["NVIDIA_API_KEY"] = clean_key
|
| 543 |
-
return clean_key, f"Dotenv File ({os.path.basename(found)})"
|
| 544 |
-
except Exception:
|
| 545 |
-
pass
|
| 546 |
-
|
| 547 |
-
# 3. System Keyring
|
| 548 |
-
if keyring:
|
| 549 |
-
candidate_services = ["system", "nvidia", "nsedash", "NVIDIA_API_KEY", "NVIDIA", "nvidia_nim"]
|
| 550 |
-
candidate_usernames = ["NVIDIA_API_KEY", "api_key", "key", "default"]
|
| 551 |
-
for svc in candidate_services:
|
| 552 |
-
for usr in candidate_usernames:
|
| 553 |
-
try:
|
| 554 |
-
secret = keyring.get_password(svc, usr)
|
| 555 |
-
if secret:
|
| 556 |
-
clean_secret = str(secret).replace("\n", "").replace("\r", "").strip()
|
| 557 |
-
if clean_secret:
|
| 558 |
-
os.environ["NVIDIA_API_KEY"] = clean_secret
|
| 559 |
-
return clean_secret, f"System Keyring ({svc}/{usr})"
|
| 560 |
-
except Exception:
|
| 561 |
-
pass
|
| 562 |
-
|
| 563 |
-
return None, "None"
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
def test_nvidia_api_connection(api_key: str = None) -> tuple[bool, str]:
|
| 567 |
-
"""
|
| 568 |
-
Test NVIDIA NIM API connection with the resolved API key.
|
| 569 |
-
Returns (success: bool, message: str).
|
| 570 |
-
"""
|
| 571 |
-
key = api_key or os.getenv("NVIDIA_API_KEY")
|
| 572 |
-
if not key:
|
| 573 |
-
return False, "NVIDIA_API_KEY is not set in environment or system keyring"
|
| 574 |
-
|
| 575 |
-
headers = {
|
| 576 |
-
"Authorization": f"Bearer {key.strip()}",
|
| 577 |
-
"Accept": "application/json"
|
| 578 |
-
}
|
| 579 |
-
try:
|
| 580 |
-
resp = requests.get("https://integrate.api.nvidia.com/v1/models", headers=headers, timeout=5)
|
| 581 |
-
if resp.status_code == 200:
|
| 582 |
-
return True, "API connection tested & successful"
|
| 583 |
-
elif resp.status_code == 401:
|
| 584 |
-
return False, "NVIDIA API Key Unauthorized (HTTP 401)"
|
| 585 |
-
else:
|
| 586 |
-
return False, f"NVIDIA API test returned HTTP status {resp.status_code}"
|
| 587 |
-
except Exception as err:
|
| 588 |
-
return False, f"NVIDIA API connection test failed: {err}"
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
def init_nvidia_api_status(force_retest: bool = False) -> dict:
|
| 592 |
-
"""Initialize key resolution and API connection test once at startup."""
|
| 593 |
-
global _NVIDIA_API_STATUS
|
| 594 |
-
if _NVIDIA_API_STATUS["tested"] and not force_retest:
|
| 595 |
-
return _NVIDIA_API_STATUS
|
| 596 |
-
|
| 597 |
-
key, source = resolve_nvidia_api_key()
|
| 598 |
-
if not key:
|
| 599 |
-
_NVIDIA_API_STATUS = {
|
| 600 |
-
"tested": True,
|
| 601 |
-
"success": False,
|
| 602 |
-
"message": "NVIDIA_API_KEY missing in environment and system keyring",
|
| 603 |
-
"source": source
|
| 604 |
-
}
|
| 605 |
-
return _NVIDIA_API_STATUS
|
| 606 |
-
|
| 607 |
-
success, msg = test_nvidia_api_connection(key)
|
| 608 |
-
_NVIDIA_API_STATUS = {
|
| 609 |
-
"tested": True,
|
| 610 |
-
"success": success,
|
| 611 |
-
"message": msg,
|
| 612 |
-
"source": source
|
| 613 |
-
}
|
| 614 |
-
if success:
|
| 615 |
-
print(f"[NVIDIA LLM] API Key loaded from {source}. Connection tested and successful.")
|
| 616 |
-
else:
|
| 617 |
-
print(f"[NVIDIA LLM] API Key loaded from {source}. Connection test warning: {msg}")
|
| 618 |
-
return _NVIDIA_API_STATUS
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
def get_nvidia_api_status() -> dict:
|
| 622 |
-
"""Return the cached or freshly initialized NVIDIA API status."""
|
| 623 |
-
return init_nvidia_api_status()
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
# ---------------------------------------------------------------------------
|
| 627 |
-
# NvidiaLLM — CrewAI-compatible wrapper with intelligent routing
|
| 628 |
-
# ---------------------------------------------------------------------------
|
| 629 |
-
class NvidiaLLM(BaseLLM):
|
| 630 |
-
"""
|
| 631 |
-
CrewAI-compatible LLM wrapper for NVIDIA NIM API.
|
| 632 |
-
Supports intelligent routing to specialist models by task category.
|
| 633 |
-
"""
|
| 634 |
-
|
| 635 |
-
def __init__(self, **kwargs):
|
| 636 |
-
# Resolve API key from environment variable first, then fallback to keyring
|
| 637 |
-
api_key = kwargs.pop("api_key", None)
|
| 638 |
-
if not api_key:
|
| 639 |
-
api_key, _ = resolve_nvidia_api_key()
|
| 640 |
-
|
| 641 |
-
if api_key:
|
| 642 |
-
api_key = str(api_key).replace("\n", "").replace("\r", "").strip()
|
| 643 |
-
|
| 644 |
-
if not api_key:
|
| 645 |
-
global _WARNED_MISSING_KEY
|
| 646 |
-
if not _WARNED_MISSING_KEY:
|
| 647 |
-
print("WARNING: NVIDIA_API_KEY is not set. Free NVIDIA NIM API features require NVIDIA_API_KEY in environment or keyring.")
|
| 648 |
-
_WARNED_MISSING_KEY = True
|
| 649 |
-
|
| 650 |
-
# Default to fast chat model
|
| 651 |
-
kwargs["api_key"] = api_key
|
| 652 |
-
kwargs.setdefault("model", "meta/llama-3.1-8b-instruct")
|
| 653 |
-
super().__init__(**kwargs)
|
| 654 |
-
|
| 655 |
-
self.max_tokens = kwargs.get("max_tokens", 4096)
|
| 656 |
-
self.temperature = kwargs.get("temperature", 1.0)
|
| 657 |
-
self.top_p = kwargs.get("top_p", 0.95)
|
| 658 |
-
self.timeout = kwargs.get("timeout", 120)
|
| 659 |
-
|
| 660 |
-
def _raw_call(self, model_id: str, formatted_messages: list, max_tokens: int, timeout: int = None) -> str:
|
| 661 |
-
"""Make a single raw API call with explicit model_id."""
|
| 662 |
-
effective_timeout = timeout or self.timeout
|
| 663 |
-
invoke_url = "https://integrate.api.nvidia.com/v1/chat/completions"
|
| 664 |
-
headers = {
|
| 665 |
-
"Authorization": f"Bearer {self.api_key}",
|
| 666 |
-
"Accept": "application/json",
|
| 667 |
-
"Content-Type": "application/json",
|
| 668 |
-
}
|
| 669 |
-
payload = {
|
| 670 |
-
"model": model_id,
|
| 671 |
-
"messages": formatted_messages,
|
| 672 |
-
"max_tokens": max_tokens,
|
| 673 |
-
"temperature": self.temperature,
|
| 674 |
-
"top_p": self.top_p,
|
| 675 |
-
"stream": False,
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
resp = requests.post(invoke_url, headers=headers, json=payload, timeout=effective_timeout)
|
| 679 |
-
resp.raise_for_status()
|
| 680 |
-
resp_data = resp.json()
|
| 681 |
-
|
| 682 |
-
# Track tokens per model
|
| 683 |
-
usage = resp_data.get("usage", {})
|
| 684 |
-
pt = usage.get("prompt_tokens", 0)
|
| 685 |
-
ct = usage.get("completion_tokens", 0)
|
| 686 |
-
if pt > 0 or ct > 0:
|
| 687 |
-
add_tokens(pt, ct, model_id=model_id)
|
| 688 |
-
|
| 689 |
-
if "choices" in resp_data and len(resp_data["choices"]) > 0:
|
| 690 |
-
message = resp_data["choices"][0].get("message", {})
|
| 691 |
-
if message.get("content"):
|
| 692 |
-
return message["content"].strip()
|
| 693 |
-
return ""
|
| 694 |
-
|
| 695 |
-
def call(self, messages: list[dict], callbacks: list[Any] | None = None, **kwargs: Any) -> str:
|
| 696 |
-
"""
|
| 697 |
-
Main entry point. Supports:
|
| 698 |
-
- hint="coding"/"judge"/"memory" etc. for explicit routing
|
| 699 |
-
- Automatic keyword-based routing if hint not provided
|
| 700 |
-
- Fallback chain through category fallback models on failure
|
| 701 |
-
"""
|
| 702 |
-
# Budget guardrail
|
| 703 |
-
current_usage = get_token_usage()
|
| 704 |
-
if current_usage.get("total_tokens", 0) > 10000000:
|
| 705 |
-
print("[AGENTS DIAG ERROR] Token budget exceeded (10M limit)! Throttling LLM call.")
|
| 706 |
-
return "[LLM ERROR] Token budget exceeded. API call blocked by guardrail."
|
| 707 |
-
|
| 708 |
-
# Normalize messages
|
| 709 |
-
if isinstance(messages, str):
|
| 710 |
-
formatted_messages = [{"role": "user", "content": messages}]
|
| 711 |
-
prompt_text = messages
|
| 712 |
-
elif isinstance(messages, list):
|
| 713 |
-
formatted_messages = messages
|
| 714 |
-
prompt_text = " ".join(m.get("content", "") for m in messages if isinstance(m, dict))
|
| 715 |
-
else:
|
| 716 |
-
formatted_messages = [{"role": "user", "content": str(messages)}]
|
| 717 |
-
prompt_text = str(messages)
|
| 718 |
-
|
| 719 |
-
max_tokens = kwargs.get("max_tokens") or self.max_tokens
|
| 720 |
-
|
| 721 |
-
# --- Intelligent Routing ---
|
| 722 |
-
hint = kwargs.get("hint") # caller can pass hint="coding" etc.
|
| 723 |
-
|
| 724 |
-
# Per-category dynamic timeouts (large models need more time on free tier)
|
| 725 |
-
CATEGORY_TIMEOUTS = {
|
| 726 |
-
"coding": 180,
|
| 727 |
-
"reasoning": 300, # 300s (5 minutes)
|
| 728 |
-
"memory": 180,
|
| 729 |
-
"judge": 120,
|
| 730 |
-
"finance": 300, # 300s (5 minutes)
|
| 731 |
-
"vision": 90,
|
| 732 |
-
"safety": 30,
|
| 733 |
-
"fast": 20,
|
| 734 |
-
"chat": 60,
|
| 735 |
-
"creative": 120,
|
| 736 |
-
"medical": 60,
|
| 737 |
-
"embedding": 30,
|
| 738 |
-
}
|
| 739 |
-
|
| 740 |
-
# Per-category max_tokens caps
|
| 741 |
-
CATEGORY_MAX_TOKENS = {
|
| 742 |
-
"coding": 8192,
|
| 743 |
-
"reasoning": 32768, # 32K context / output
|
| 744 |
-
"memory": 8192,
|
| 745 |
-
"judge": 2048,
|
| 746 |
-
"finance": 32768, # 32K context / output
|
| 747 |
-
"chat": 2048,
|
| 748 |
-
"fast": 512,
|
| 749 |
-
"creative": 4096,
|
| 750 |
-
"medical": 2048,
|
| 751 |
-
"safety": 256,
|
| 752 |
-
"embedding": 512,
|
| 753 |
-
}
|
| 754 |
-
|
| 755 |
-
# --- Intelligent Routing & Explicit Overrides ---
|
| 756 |
-
hint = kwargs.get("hint") # caller can pass hint="coding" etc.
|
| 757 |
-
explicit_model = kwargs.get("model") or kwargs.get("selected_model") or (self.model if self.model != "meta/llama-3.1-8b-instruct" else None)
|
| 758 |
-
explicit_timeout = kwargs.get("timeout")
|
| 759 |
-
|
| 760 |
-
# Determine category, primary model, and fallbacks
|
| 761 |
-
if explicit_model:
|
| 762 |
-
category = hint or "reasoning"
|
| 763 |
-
model_id = explicit_model
|
| 764 |
-
registry_entry = NVIDIA_MODEL_REGISTRY.get(category, {})
|
| 765 |
-
all_fallbacks = registry_entry.get("fallback", [])
|
| 766 |
-
fallbacks = [m for m in all_fallbacks if m != model_id]
|
| 767 |
-
else:
|
| 768 |
-
category, model_id = route_request(prompt_text, hint=hint)
|
| 769 |
-
registry_entry = NVIDIA_MODEL_REGISTRY.get(category, {})
|
| 770 |
-
fallbacks = registry_entry.get("fallback", [])
|
| 771 |
-
|
| 772 |
-
# Build candidate chain starting with primary/selected model
|
| 773 |
-
candidates = []
|
| 774 |
-
for cand in [model_id] + fallbacks:
|
| 775 |
-
if cand and cand not in candidates:
|
| 776 |
-
candidates.append(cand)
|
| 777 |
-
|
| 778 |
-
# Apply timeout override or category-tuned timeout
|
| 779 |
-
if explicit_timeout:
|
| 780 |
-
try:
|
| 781 |
-
effective_timeout = int(explicit_timeout)
|
| 782 |
-
except (ValueError, TypeError):
|
| 783 |
-
effective_timeout = CATEGORY_TIMEOUTS.get(category, self.timeout)
|
| 784 |
-
else:
|
| 785 |
-
effective_timeout = CATEGORY_TIMEOUTS.get(category, self.timeout)
|
| 786 |
-
|
| 787 |
-
effective_max_tokens = min(max_tokens, CATEGORY_MAX_TOKENS.get(category, max_tokens))
|
| 788 |
-
|
| 789 |
-
print(f"[NVIDIA LLM ROUTER] Primary/Selected={model_id} | Category={category} | Total Candidates={len(candidates)} | Overall Budget={effective_timeout}s")
|
| 790 |
-
|
| 791 |
-
last_error = None
|
| 792 |
-
start_t = time.time()
|
| 793 |
-
detailed_req = kwargs.get("detailed", False)
|
| 794 |
-
|
| 795 |
-
for idx, candidate in enumerate(candidates):
|
| 796 |
-
elapsed_so_far = time.time() - start_t
|
| 797 |
-
remaining_budget = effective_timeout - elapsed_so_far
|
| 798 |
-
if remaining_budget <= 3:
|
| 799 |
-
print(f"[NVIDIA LLM ROUTER] Overall timeout budget ({effective_timeout}s) nearly exhausted ({elapsed_so_far:.1f}s elapsed). Stopping candidate loop.")
|
| 800 |
-
break
|
| 801 |
-
|
| 802 |
-
# Cap each candidate call to 35s max to move quickly to responsive models on free tier
|
| 803 |
-
cand_timeout = min(35, int(remaining_budget))
|
| 804 |
-
print(f"[NVIDIA LLM ROUTER] Candidate [{idx+1}/{len(candidates)}]: {candidate} (call timeout={cand_timeout}s, remaining budget={int(remaining_budget)}s)")
|
| 805 |
-
|
| 806 |
-
try:
|
| 807 |
-
result = self._raw_call_detailed(candidate, formatted_messages, effective_max_tokens, timeout=cand_timeout)
|
| 808 |
-
if result and result.get("text"):
|
| 809 |
-
if candidate != model_id:
|
| 810 |
-
print(f"[NVIDIA LLM ROUTER] Fallback model successfully generated report: {candidate}")
|
| 811 |
-
result["elapsed_seconds"] = round(time.time() - start_t, 2)
|
| 812 |
-
return result if detailed_req else result["text"]
|
| 813 |
-
except requests.exceptions.HTTPError as e:
|
| 814 |
-
last_error = f"[LLM HTTP ERROR] {e}"
|
| 815 |
-
if hasattr(e, "response") and e.response is not None and e.response.status_code == 401:
|
| 816 |
-
err_msg = "[LLM ERROR] Unauthorized: Invalid NVIDIA_API_KEY."
|
| 817 |
-
return {"text": err_msg, "model_id": candidate} if detailed_req else err_msg
|
| 818 |
-
print(f"[NVIDIA LLM ROUTER] Model {candidate} failed ({e}), trying next candidate...")
|
| 819 |
-
except requests.exceptions.ConnectionError:
|
| 820 |
-
last_error = "[LLM OFFLINE] Cannot connect to the NVIDIA API."
|
| 821 |
-
print(f"[NVIDIA LLM ROUTER] Connection error for {candidate}, trying next candidate...")
|
| 822 |
-
except requests.exceptions.Timeout:
|
| 823 |
-
last_error = f"[LLM TIMEOUT] Model {candidate} timed out after {cand_timeout}s."
|
| 824 |
-
print(f"[NVIDIA LLM ROUTER] Model {candidate} timed out after {cand_timeout}s, trying next candidate...")
|
| 825 |
-
except Exception as e:
|
| 826 |
-
last_error = f"[LLM ERROR] {str(e)}"
|
| 827 |
-
print(f"[NVIDIA LLM ROUTER] Model {candidate} error: {e}, trying next candidate...")
|
| 828 |
-
|
| 829 |
-
err_out = last_error or "[LLM ERROR] All models in fallback chain failed or budget exhausted."
|
| 830 |
-
return {"text": err_out, "model_id": model_id} if detailed_req else err_out
|
| 831 |
-
|
| 832 |
-
def _raw_call_detailed(self, model_id: str, formatted_messages: list, max_tokens: int, timeout: int = None) -> dict:
|
| 833 |
-
"""Make a raw API call and return text + usage metadata."""
|
| 834 |
-
effective_timeout = timeout or self.timeout
|
| 835 |
-
invoke_url = "https://integrate.api.nvidia.com/v1/chat/completions"
|
| 836 |
-
headers = {
|
| 837 |
-
"Authorization": f"Bearer {self.api_key}",
|
| 838 |
-
"Accept": "application/json",
|
| 839 |
-
"Content-Type": "application/json",
|
| 840 |
-
}
|
| 841 |
-
payload = {
|
| 842 |
-
"model": model_id,
|
| 843 |
-
"messages": formatted_messages,
|
| 844 |
-
"max_tokens": max_tokens,
|
| 845 |
-
"temperature": self.temperature,
|
| 846 |
-
"top_p": self.top_p,
|
| 847 |
-
"stream": False,
|
| 848 |
-
}
|
| 849 |
-
|
| 850 |
-
resp = requests.post(invoke_url, headers=headers, json=payload, timeout=effective_timeout)
|
| 851 |
-
resp.raise_for_status()
|
| 852 |
-
resp_data = resp.json()
|
| 853 |
-
|
| 854 |
-
usage = resp_data.get("usage", {})
|
| 855 |
-
pt = usage.get("prompt_tokens", 0)
|
| 856 |
-
ct = usage.get("completion_tokens", 0)
|
| 857 |
-
if pt > 0 or ct > 0:
|
| 858 |
-
add_tokens(pt, ct, model_id=model_id)
|
| 859 |
-
|
| 860 |
-
content = ""
|
| 861 |
-
if "choices" in resp_data and len(resp_data["choices"]) > 0:
|
| 862 |
-
message = resp_data["choices"][0].get("message", {})
|
| 863 |
-
if message.get("content"):
|
| 864 |
-
content = message["content"].strip()
|
| 865 |
-
|
| 866 |
-
return {
|
| 867 |
-
"text": content,
|
| 868 |
-
"model_id": model_id,
|
| 869 |
-
"prompt_tokens": pt,
|
| 870 |
-
"completion_tokens": ct,
|
| 871 |
-
"total_tokens": pt + ct,
|
| 872 |
-
}
|
| 873 |
-
|
| 874 |
-
def supports_function_calling(self) -> bool:
|
| 875 |
-
return False
|
| 876 |
-
|
| 877 |
-
def supports_stop_words(self) -> bool:
|
| 878 |
-
return False
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
# ---------------------------------------------------------------------------
|
| 882 |
-
# Convenience factory functions for specific categories
|
| 883 |
-
# ---------------------------------------------------------------------------
|
| 884 |
-
def get_coding_llm(**kwargs) -> NvidiaLLM:
|
| 885 |
-
"""Return an NvidiaLLM pre-configured for coding tasks."""
|
| 886 |
-
return NvidiaLLM(model=get_best_model("coding"), **kwargs)
|
| 887 |
-
|
| 888 |
-
def get_reasoning_llm(**kwargs) -> NvidiaLLM:
|
| 889 |
-
"""Return an NvidiaLLM pre-configured for complex reasoning."""
|
| 890 |
-
return NvidiaLLM(model=get_best_model("reasoning"), **kwargs)
|
| 891 |
-
|
| 892 |
-
def get_memory_llm(**kwargs) -> NvidiaLLM:
|
| 893 |
-
"""Return an NvidiaLLM pre-configured for memory/summarization."""
|
| 894 |
-
return NvidiaLLM(model=get_best_model("memory"), **kwargs)
|
| 895 |
-
|
| 896 |
-
def get_judge_llm(**kwargs) -> NvidiaLLM:
|
| 897 |
-
"""Return an NvidiaLLM pre-configured for LLM-as-a-judge evaluation."""
|
| 898 |
-
return NvidiaLLM(model=get_best_model("judge"), **kwargs)
|
| 899 |
-
|
| 900 |
-
def get_finance_llm(**kwargs) -> NvidiaLLM:
|
| 901 |
-
"""Return an NvidiaLLM pre-configured for financial analysis."""
|
| 902 |
-
return NvidiaLLM(model=get_best_model("finance"), **kwargs)
|
| 903 |
-
|
| 904 |
-
def get_fast_llm(**kwargs) -> NvidiaLLM:
|
| 905 |
-
"""Return an NvidiaLLM pre-configured for fast/lightweight tasks."""
|
| 906 |
-
return NvidiaLLM(model=get_best_model("fast"), **kwargs)
|
| 907 |
-
|
| 908 |
-
def get_creative_llm(**kwargs) -> NvidiaLLM:
|
| 909 |
-
"""Return an NvidiaLLM pre-configured for creative writing and documentation."""
|
| 910 |
-
return NvidiaLLM(model=get_best_model("creative"), **kwargs)
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
# ---------------------------------------------------------------------------
|
| 914 |
-
# Default singleton (chat model, auto-routing enabled)
|
| 915 |
-
# ---------------------------------------------------------------------------
|
| 916 |
-
_llm_instance = None
|
| 917 |
-
|
| 918 |
-
def get_nvidia_llm() -> NvidiaLLM:
|
| 919 |
-
global _llm_instance
|
| 920 |
-
if _llm_instance is None:
|
| 921 |
-
_llm_instance = NvidiaLLM()
|
| 922 |
-
return _llm_instance
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
# ---------------------------------------------------------------------------
|
| 926 |
-
# Eager initialization on main thread + save registry to JSON
|
| 927 |
-
# ---------------------------------------------------------------------------
|
| 928 |
-
try:
|
| 929 |
-
init_nvidia_api_status()
|
| 930 |
-
get_nvidia_llm()
|
| 931 |
-
save_model_registry()
|
| 932 |
-
except Exception as e:
|
| 933 |
-
print(f"[NVIDIA LLM] Initialization warning: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/nvidia_model_registry.json
DELETED
|
@@ -1,981 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"updated_at": "2026-07-30T01:20:19.883433",
|
| 3 |
-
"total_nvidia_nim_models": 74,
|
| 4 |
-
"categories": [
|
| 5 |
-
"coding",
|
| 6 |
-
"reasoning",
|
| 7 |
-
"chat",
|
| 8 |
-
"memory",
|
| 9 |
-
"safety",
|
| 10 |
-
"translation",
|
| 11 |
-
"embedding",
|
| 12 |
-
"vision",
|
| 13 |
-
"fast",
|
| 14 |
-
"science",
|
| 15 |
-
"multimodal_vision",
|
| 16 |
-
"audio"
|
| 17 |
-
],
|
| 18 |
-
"registry": {
|
| 19 |
-
"coding": {
|
| 20 |
-
"primary": "deepseek-ai/deepseek-v4-pro",
|
| 21 |
-
"fallback": [
|
| 22 |
-
"deepseek-ai/deepseek-v4-flash",
|
| 23 |
-
"poolside/laguna-xs-2.1",
|
| 24 |
-
"abacusai/dracarys-llama-3.1-70b-instruct",
|
| 25 |
-
"nvidia/nv-embedcode-7b-v1"
|
| 26 |
-
],
|
| 27 |
-
"models": {
|
| 28 |
-
"deepseek-ai/deepseek-v4-pro": {
|
| 29 |
-
"rating": 5,
|
| 30 |
-
"context_window": 163840,
|
| 31 |
-
"description": "State-of-the-art coder. Outstanding performance on code generation and multi-step agent debugging.",
|
| 32 |
-
"strengths": [
|
| 33 |
-
"code_gen",
|
| 34 |
-
"agentic",
|
| 35 |
-
"debugging"
|
| 36 |
-
]
|
| 37 |
-
},
|
| 38 |
-
"deepseek-ai/deepseek-v4-flash": {
|
| 39 |
-
"rating": 4,
|
| 40 |
-
"context_window": 163840,
|
| 41 |
-
"description": "High-throughput coding model. Ideal for low-latency coding loops.",
|
| 42 |
-
"strengths": [
|
| 43 |
-
"code_gen",
|
| 44 |
-
"fast",
|
| 45 |
-
"agentic"
|
| 46 |
-
]
|
| 47 |
-
},
|
| 48 |
-
"poolside/laguna-xs-2.1": {
|
| 49 |
-
"rating": 5,
|
| 50 |
-
"context_window": 32768,
|
| 51 |
-
"description": "Poolside Laguna XS. Specially trained for agentic coding and long-horizon developer workflows.",
|
| 52 |
-
"strengths": [
|
| 53 |
-
"code_gen",
|
| 54 |
-
"agentic",
|
| 55 |
-
"planning"
|
| 56 |
-
]
|
| 57 |
-
},
|
| 58 |
-
"abacusai/dracarys-llama-3.1-70b-instruct": {
|
| 59 |
-
"rating": 4,
|
| 60 |
-
"context_window": 131072,
|
| 61 |
-
"description": "Abacus.AI fine-tune of Llama 3.1 70B, optimized for complex code instruction following.",
|
| 62 |
-
"strengths": [
|
| 63 |
-
"code_gen",
|
| 64 |
-
"instruction"
|
| 65 |
-
]
|
| 66 |
-
},
|
| 67 |
-
"nvidia/nv-embedcode-7b-v1": {
|
| 68 |
-
"rating": 3,
|
| 69 |
-
"context_window": 8192,
|
| 70 |
-
"description": "NVIDIA specialized coding embedding model for semantic code search.",
|
| 71 |
-
"strengths": [
|
| 72 |
-
"embedding",
|
| 73 |
-
"code_search"
|
| 74 |
-
]
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
},
|
| 78 |
-
"reasoning": {
|
| 79 |
-
"primary": "google/diffusiongemma-26b-a4b-it",
|
| 80 |
-
"fallback": [
|
| 81 |
-
"meta/llama-3.1-70b-instruct",
|
| 82 |
-
"minimaxai/minimax-m3",
|
| 83 |
-
"z-ai/glm-5.2",
|
| 84 |
-
"nvidia/nemotron-3-ultra-550b-a55b",
|
| 85 |
-
"thinkingmachines/inkling",
|
| 86 |
-
"meta/llama-3.3-70b-instruct",
|
| 87 |
-
"nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
| 88 |
-
"meta/llama-3.1-8b-instruct"
|
| 89 |
-
],
|
| 90 |
-
"models": {
|
| 91 |
-
"minimaxai/minimax-m3": {
|
| 92 |
-
"rating": 5,
|
| 93 |
-
"context_window": 128000,
|
| 94 |
-
"description": "MiniMax-M3 SOTA reasoning model. Exceptional financial analysis and long context planning.",
|
| 95 |
-
"strengths": [
|
| 96 |
-
"reasoning",
|
| 97 |
-
"finance",
|
| 98 |
-
"instruction"
|
| 99 |
-
]
|
| 100 |
-
},
|
| 101 |
-
"google/diffusiongemma-26b-a4b-it": {
|
| 102 |
-
"rating": 5,
|
| 103 |
-
"context_window": 128000,
|
| 104 |
-
"description": "Google DiffusionGemma 26B A4B IT. Next-gen reasoning and fast inference.",
|
| 105 |
-
"strengths": [
|
| 106 |
-
"reasoning",
|
| 107 |
-
"finance",
|
| 108 |
-
"agentic"
|
| 109 |
-
]
|
| 110 |
-
},
|
| 111 |
-
"z-ai/glm-5.2": {
|
| 112 |
-
"rating": 5,
|
| 113 |
-
"context_window": 128000,
|
| 114 |
-
"description": "GLM-5.2 SOTA reasoning model. Outstanding multi-step planning and financial instruction following.",
|
| 115 |
-
"strengths": [
|
| 116 |
-
"reasoning",
|
| 117 |
-
"finance",
|
| 118 |
-
"instruction"
|
| 119 |
-
]
|
| 120 |
-
},
|
| 121 |
-
"nvidia/nemotron-3-ultra-550b-a55b": {
|
| 122 |
-
"rating": 5,
|
| 123 |
-
"context_window": 128000,
|
| 124 |
-
"description": "NVIDIA 550B reasoning model. Exceptional logical analysis.",
|
| 125 |
-
"strengths": [
|
| 126 |
-
"reasoning",
|
| 127 |
-
"agentic",
|
| 128 |
-
"math"
|
| 129 |
-
]
|
| 130 |
-
},
|
| 131 |
-
"thinkingmachines/inkling": {
|
| 132 |
-
"rating": 4,
|
| 133 |
-
"context_window": 32768,
|
| 134 |
-
"description": "Inkling 975B MoE reasoning model. Excels at complex analysis.",
|
| 135 |
-
"strengths": [
|
| 136 |
-
"reasoning",
|
| 137 |
-
"moe"
|
| 138 |
-
]
|
| 139 |
-
},
|
| 140 |
-
"meta/llama-3.1-70b-instruct": {
|
| 141 |
-
"rating": 5,
|
| 142 |
-
"context_window": 128000,
|
| 143 |
-
"description": "Meta Llama 3.1 70B Instruct. High throughput, low latency.",
|
| 144 |
-
"strengths": [
|
| 145 |
-
"reasoning",
|
| 146 |
-
"finance",
|
| 147 |
-
"fast"
|
| 148 |
-
]
|
| 149 |
-
},
|
| 150 |
-
"meta/llama-3.3-70b-instruct": {
|
| 151 |
-
"rating": 5,
|
| 152 |
-
"context_window": 128000,
|
| 153 |
-
"description": "Meta Llama 3.3 70B Instruct.",
|
| 154 |
-
"strengths": [
|
| 155 |
-
"reasoning",
|
| 156 |
-
"finance"
|
| 157 |
-
]
|
| 158 |
-
},
|
| 159 |
-
"nvidia/llama-3.3-nemotron-super-49b-v1.5": {
|
| 160 |
-
"rating": 4,
|
| 161 |
-
"context_window": 128000,
|
| 162 |
-
"description": "NVIDIA Nemotron Super 49B v1.5.",
|
| 163 |
-
"strengths": [
|
| 164 |
-
"reasoning",
|
| 165 |
-
"agentic"
|
| 166 |
-
]
|
| 167 |
-
},
|
| 168 |
-
"meta/llama-3.1-8b-instruct": {
|
| 169 |
-
"rating": 4,
|
| 170 |
-
"context_window": 128000,
|
| 171 |
-
"description": "Llama 3.1 8B Instruct. Fast fallback.",
|
| 172 |
-
"strengths": [
|
| 173 |
-
"fast",
|
| 174 |
-
"general"
|
| 175 |
-
]
|
| 176 |
-
}
|
| 177 |
-
}
|
| 178 |
-
},
|
| 179 |
-
"chat": {
|
| 180 |
-
"primary": "meta/llama-3.3-70b-instruct",
|
| 181 |
-
"fallback": [
|
| 182 |
-
"meta/llama-3.1-70b-instruct",
|
| 183 |
-
"meta/llama-3.1-8b-instruct",
|
| 184 |
-
"minimaxai/minimax-m3",
|
| 185 |
-
"mistralai/mistral-medium-3.5-128b",
|
| 186 |
-
"google/gemma-4-31b-it",
|
| 187 |
-
"mistralai/mistral-small-4-119b-2603",
|
| 188 |
-
"qwen/qwen3.5-122b-a10b",
|
| 189 |
-
"nvidia/nemotron-3-nano-30b-a3b",
|
| 190 |
-
"mistralai/ministral-14b-instruct-2512",
|
| 191 |
-
"bytedance/seed-oss-36b-instruct",
|
| 192 |
-
"nvidia/nvidia-nemotron-nano-9b-v2",
|
| 193 |
-
"openai/gpt-oss-20b",
|
| 194 |
-
"openai/gpt-oss-120b",
|
| 195 |
-
"sarvamai/sarvam-m",
|
| 196 |
-
"google/gemma-3n-e4b-it",
|
| 197 |
-
"google/gemma-3n-e2b-it",
|
| 198 |
-
"mistralai/mistral-nemotron",
|
| 199 |
-
"nvidia/llama-3.1-nemotron-nano-8b-v1",
|
| 200 |
-
"upstage/solar-10.7b-instruct",
|
| 201 |
-
"mistralai/mixtral-8x7b-instruct-v0.1",
|
| 202 |
-
"minimaxai/minimax-m2.7",
|
| 203 |
-
"stepfun-ai/step-3.7-flash",
|
| 204 |
-
"stepfun-ai/step-3.5-flash"
|
| 205 |
-
],
|
| 206 |
-
"models": {
|
| 207 |
-
"meta/llama-3.3-70b-instruct": {
|
| 208 |
-
"rating": 5,
|
| 209 |
-
"context_window": 128000,
|
| 210 |
-
"description": "Meta Llama 3.3 70B. High-quality instruction following, default conversational model.",
|
| 211 |
-
"strengths": [
|
| 212 |
-
"chat",
|
| 213 |
-
"general",
|
| 214 |
-
"instruction"
|
| 215 |
-
]
|
| 216 |
-
},
|
| 217 |
-
"meta/llama-3.1-70b-instruct": {
|
| 218 |
-
"rating": 4,
|
| 219 |
-
"context_window": 128000,
|
| 220 |
-
"description": "Meta Llama 3.1 70B. Excellent instruction execution and formatting.",
|
| 221 |
-
"strengths": [
|
| 222 |
-
"chat",
|
| 223 |
-
"general"
|
| 224 |
-
]
|
| 225 |
-
},
|
| 226 |
-
"meta/llama-3.1-8b-instruct": {
|
| 227 |
-
"rating": 4,
|
| 228 |
-
"context_window": 128000,
|
| 229 |
-
"description": "Llama 3.1 8B. Fast, reliable conversational chat model.",
|
| 230 |
-
"strengths": [
|
| 231 |
-
"chat",
|
| 232 |
-
"fast",
|
| 233 |
-
"general"
|
| 234 |
-
]
|
| 235 |
-
},
|
| 236 |
-
"minimaxai/minimax-m3": {
|
| 237 |
-
"rating": 4,
|
| 238 |
-
"context_window": 40960,
|
| 239 |
-
"description": "MiniMax M3. Strong general assistant, optimized for long-form dialogue.",
|
| 240 |
-
"strengths": [
|
| 241 |
-
"chat",
|
| 242 |
-
"dialogue"
|
| 243 |
-
]
|
| 244 |
-
},
|
| 245 |
-
"minimaxai/minimax-m2.7": {
|
| 246 |
-
"rating": 4,
|
| 247 |
-
"context_window": 40960,
|
| 248 |
-
"description": "MiniMax M2.7. Fast conversational LLM.",
|
| 249 |
-
"strengths": [
|
| 250 |
-
"chat",
|
| 251 |
-
"fast"
|
| 252 |
-
]
|
| 253 |
-
},
|
| 254 |
-
"stepfun-ai/step-3.7-flash": {
|
| 255 |
-
"rating": 4,
|
| 256 |
-
"context_window": 128000,
|
| 257 |
-
"description": "StepFun 3.7 Flash. Ultra-fast high-concurrency conversational assistant.",
|
| 258 |
-
"strengths": [
|
| 259 |
-
"chat",
|
| 260 |
-
"fast"
|
| 261 |
-
]
|
| 262 |
-
},
|
| 263 |
-
"stepfun-ai/step-3.5-flash": {
|
| 264 |
-
"rating": 3,
|
| 265 |
-
"context_window": 128000,
|
| 266 |
-
"description": "StepFun 3.5 Flash model.",
|
| 267 |
-
"strengths": [
|
| 268 |
-
"chat",
|
| 269 |
-
"fast"
|
| 270 |
-
]
|
| 271 |
-
},
|
| 272 |
-
"mistralai/mistral-medium-3.5-128b": {
|
| 273 |
-
"rating": 4,
|
| 274 |
-
"context_window": 128000,
|
| 275 |
-
"description": "Mistral Medium 3.5. Balanced instruct model with great reasoning capacity.",
|
| 276 |
-
"strengths": [
|
| 277 |
-
"chat",
|
| 278 |
-
"instruction"
|
| 279 |
-
]
|
| 280 |
-
},
|
| 281 |
-
"google/gemma-4-31b-it": {
|
| 282 |
-
"rating": 4,
|
| 283 |
-
"context_window": 128000,
|
| 284 |
-
"description": "Google Gemma 4 31B instruction-tuned model. Top scores in general chat.",
|
| 285 |
-
"strengths": [
|
| 286 |
-
"chat",
|
| 287 |
-
"instruction"
|
| 288 |
-
]
|
| 289 |
-
},
|
| 290 |
-
"mistralai/mistral-small-4-119b-2603": {
|
| 291 |
-
"rating": 4,
|
| 292 |
-
"context_window": 32768,
|
| 293 |
-
"description": "Mistral Small 4 119B. High quality intermediate model for general instruct tasks.",
|
| 294 |
-
"strengths": [
|
| 295 |
-
"chat",
|
| 296 |
-
"instruction"
|
| 297 |
-
]
|
| 298 |
-
},
|
| 299 |
-
"qwen/qwen3.5-122b-a10b": {
|
| 300 |
-
"rating": 4,
|
| 301 |
-
"context_window": 32768,
|
| 302 |
-
"description": "Qwen3.5 122B MoE model, highly conversational.",
|
| 303 |
-
"strengths": [
|
| 304 |
-
"chat",
|
| 305 |
-
"moe"
|
| 306 |
-
]
|
| 307 |
-
},
|
| 308 |
-
"nvidia/nemotron-3-nano-30b-a3b": {
|
| 309 |
-
"rating": 3,
|
| 310 |
-
"context_window": 32768,
|
| 311 |
-
"description": "NVIDIA Nemotron 3 Nano.",
|
| 312 |
-
"strengths": [
|
| 313 |
-
"chat",
|
| 314 |
-
"fast"
|
| 315 |
-
]
|
| 316 |
-
},
|
| 317 |
-
"mistralai/ministral-14b-instruct-2512": {
|
| 318 |
-
"rating": 3,
|
| 319 |
-
"context_window": 32768,
|
| 320 |
-
"description": "Ministral 14B instruction model.",
|
| 321 |
-
"strengths": [
|
| 322 |
-
"chat",
|
| 323 |
-
"fast"
|
| 324 |
-
]
|
| 325 |
-
},
|
| 326 |
-
"bytedance/seed-oss-36b-instruct": {
|
| 327 |
-
"rating": 4,
|
| 328 |
-
"context_window": 32768,
|
| 329 |
-
"description": "ByteDance Seed OSS 36B.",
|
| 330 |
-
"strengths": [
|
| 331 |
-
"chat",
|
| 332 |
-
"general"
|
| 333 |
-
]
|
| 334 |
-
},
|
| 335 |
-
"nvidia/nvidia-nemotron-nano-9b-v2": {
|
| 336 |
-
"rating": 3,
|
| 337 |
-
"context_window": 32768,
|
| 338 |
-
"description": "NVIDIA Nemotron Nano 9B v2.",
|
| 339 |
-
"strengths": [
|
| 340 |
-
"chat",
|
| 341 |
-
"fast"
|
| 342 |
-
]
|
| 343 |
-
},
|
| 344 |
-
"openai/gpt-oss-20b": {
|
| 345 |
-
"rating": 4,
|
| 346 |
-
"context_window": 32768,
|
| 347 |
-
"description": "OpenAI GPT-OSS 20B model.",
|
| 348 |
-
"strengths": [
|
| 349 |
-
"chat",
|
| 350 |
-
"general"
|
| 351 |
-
]
|
| 352 |
-
},
|
| 353 |
-
"openai/gpt-oss-120b": {
|
| 354 |
-
"rating": 4,
|
| 355 |
-
"context_window": 32768,
|
| 356 |
-
"description": "OpenAI GPT-OSS 120B model.",
|
| 357 |
-
"strengths": [
|
| 358 |
-
"chat",
|
| 359 |
-
"general"
|
| 360 |
-
]
|
| 361 |
-
},
|
| 362 |
-
"sarvamai/sarvam-m": {
|
| 363 |
-
"rating": 3,
|
| 364 |
-
"context_window": 8192,
|
| 365 |
-
"description": "Sarvam-M multilingual chat model.",
|
| 366 |
-
"strengths": [
|
| 367 |
-
"chat",
|
| 368 |
-
"multilingual"
|
| 369 |
-
]
|
| 370 |
-
},
|
| 371 |
-
"google/gemma-3n-e4b-it": {
|
| 372 |
-
"rating": 4,
|
| 373 |
-
"context_window": 128000,
|
| 374 |
-
"description": "Google Gemma 3N E4B model.",
|
| 375 |
-
"strengths": [
|
| 376 |
-
"chat",
|
| 377 |
-
"general"
|
| 378 |
-
]
|
| 379 |
-
},
|
| 380 |
-
"google/gemma-3n-e2b-it": {
|
| 381 |
-
"rating": 4,
|
| 382 |
-
"context_window": 128000,
|
| 383 |
-
"description": "Google Gemma 3N E2B model.",
|
| 384 |
-
"strengths": [
|
| 385 |
-
"chat",
|
| 386 |
-
"general"
|
| 387 |
-
]
|
| 388 |
-
},
|
| 389 |
-
"mistralai/mistral-nemotron": {
|
| 390 |
-
"rating": 4,
|
| 391 |
-
"context_window": 128000,
|
| 392 |
-
"description": "Mistral-Nemotron collaboration model.",
|
| 393 |
-
"strengths": [
|
| 394 |
-
"chat",
|
| 395 |
-
"general"
|
| 396 |
-
]
|
| 397 |
-
},
|
| 398 |
-
"nvidia/llama-3.1-nemotron-nano-8b-v1": {
|
| 399 |
-
"rating": 3,
|
| 400 |
-
"context_window": 128000,
|
| 401 |
-
"description": "NVIDIA tuned Llama 3.1 Nano 8B.",
|
| 402 |
-
"strengths": [
|
| 403 |
-
"chat",
|
| 404 |
-
"fast"
|
| 405 |
-
]
|
| 406 |
-
},
|
| 407 |
-
"upstage/solar-10.7b-instruct": {
|
| 408 |
-
"rating": 3,
|
| 409 |
-
"context_window": 4096,
|
| 410 |
-
"description": "Upstage Solar 10.7B instruction-tuned model.",
|
| 411 |
-
"strengths": [
|
| 412 |
-
"chat",
|
| 413 |
-
"instruction"
|
| 414 |
-
]
|
| 415 |
-
},
|
| 416 |
-
"mistralai/mixtral-8x7b-instruct-v0.1": {
|
| 417 |
-
"rating": 3,
|
| 418 |
-
"context_window": 32768,
|
| 419 |
-
"description": "Mixtral 8x7B MoE model.",
|
| 420 |
-
"strengths": [
|
| 421 |
-
"chat",
|
| 422 |
-
"moe"
|
| 423 |
-
]
|
| 424 |
-
}
|
| 425 |
-
}
|
| 426 |
-
},
|
| 427 |
-
"memory": {
|
| 428 |
-
"primary": "meta/llama-4-maverick-17b-128e-instruct",
|
| 429 |
-
"fallback": [
|
| 430 |
-
"moonshotai/kimi-k2.6"
|
| 431 |
-
],
|
| 432 |
-
"models": {
|
| 433 |
-
"meta/llama-4-maverick-17b-128e-instruct": {
|
| 434 |
-
"rating": 5,
|
| 435 |
-
"context_window": 1000000,
|
| 436 |
-
"description": "Llama 4 Maverick. 1M token context, perfect for large codebase digests.",
|
| 437 |
-
"strengths": [
|
| 438 |
-
"summarization",
|
| 439 |
-
"long_context",
|
| 440 |
-
"memory"
|
| 441 |
-
]
|
| 442 |
-
},
|
| 443 |
-
"moonshotai/kimi-k2.6": {
|
| 444 |
-
"rating": 4,
|
| 445 |
-
"context_window": 131072,
|
| 446 |
-
"description": "Kimi K2.6. 128K context window, highly accurate long-document analysis.",
|
| 447 |
-
"strengths": [
|
| 448 |
-
"summarization",
|
| 449 |
-
"long_context"
|
| 450 |
-
]
|
| 451 |
-
}
|
| 452 |
-
}
|
| 453 |
-
},
|
| 454 |
-
"safety": {
|
| 455 |
-
"primary": "nvidia/nemotron-3.5-content-safety",
|
| 456 |
-
"fallback": [
|
| 457 |
-
"meta/llama-guard-4-12b",
|
| 458 |
-
"nvidia/gliner-pii",
|
| 459 |
-
"nvidia/llama-3.1-nemotron-safety-guard-8b-v3"
|
| 460 |
-
],
|
| 461 |
-
"models": {
|
| 462 |
-
"nvidia/nemotron-3.5-content-safety": {
|
| 463 |
-
"rating": 5,
|
| 464 |
-
"context_window": 4096,
|
| 465 |
-
"description": "NVIDIA specialized content safety model.",
|
| 466 |
-
"strengths": [
|
| 467 |
-
"safety",
|
| 468 |
-
"moderation"
|
| 469 |
-
]
|
| 470 |
-
},
|
| 471 |
-
"meta/llama-guard-4-12b": {
|
| 472 |
-
"rating": 4,
|
| 473 |
-
"context_window": 128000,
|
| 474 |
-
"description": "Llama Guard 4 12B safety classifier.",
|
| 475 |
-
"strengths": [
|
| 476 |
-
"safety",
|
| 477 |
-
"moderation"
|
| 478 |
-
]
|
| 479 |
-
},
|
| 480 |
-
"nvidia/gliner-pii": {
|
| 481 |
-
"rating": 4,
|
| 482 |
-
"context_window": 8192,
|
| 483 |
-
"description": "GLiNER PII recognition model for anonymization.",
|
| 484 |
-
"strengths": [
|
| 485 |
-
"safety",
|
| 486 |
-
"pii_redaction"
|
| 487 |
-
]
|
| 488 |
-
},
|
| 489 |
-
"nvidia/llama-3.1-nemotron-safety-guard-8b-v3": {
|
| 490 |
-
"rating": 4,
|
| 491 |
-
"context_window": 8192,
|
| 492 |
-
"description": "NVIDIA safety guard 8B v3.",
|
| 493 |
-
"strengths": [
|
| 494 |
-
"safety",
|
| 495 |
-
"moderation"
|
| 496 |
-
]
|
| 497 |
-
}
|
| 498 |
-
}
|
| 499 |
-
},
|
| 500 |
-
"translation": {
|
| 501 |
-
"primary": "nvidia/riva-translate-4b-instruct-v1.1",
|
| 502 |
-
"models": {
|
| 503 |
-
"nvidia/riva-translate-4b-instruct-v1.1": {
|
| 504 |
-
"rating": 4,
|
| 505 |
-
"context_window": 4096,
|
| 506 |
-
"description": "Riva machine translation model.",
|
| 507 |
-
"strengths": [
|
| 508 |
-
"translation"
|
| 509 |
-
]
|
| 510 |
-
}
|
| 511 |
-
}
|
| 512 |
-
},
|
| 513 |
-
"embedding": {
|
| 514 |
-
"primary": "nvidia/nv-embedcode-7b-v1",
|
| 515 |
-
"fallback": [
|
| 516 |
-
"nvidia/nemotron-3-embed-1b",
|
| 517 |
-
"nvidia/rerank-qa-mistral-4b"
|
| 518 |
-
],
|
| 519 |
-
"models": {
|
| 520 |
-
"nvidia/nv-embedcode-7b-v1": {
|
| 521 |
-
"rating": 4,
|
| 522 |
-
"context_window": 8192,
|
| 523 |
-
"description": "NVIDIA specialized code embedding model.",
|
| 524 |
-
"strengths": [
|
| 525 |
-
"embedding",
|
| 526 |
-
"code_search"
|
| 527 |
-
]
|
| 528 |
-
},
|
| 529 |
-
"nvidia/nemotron-3-embed-1b": {
|
| 530 |
-
"rating": 3,
|
| 531 |
-
"context_window": 512,
|
| 532 |
-
"description": "Nemotron-3 1B embedding model.",
|
| 533 |
-
"strengths": [
|
| 534 |
-
"embedding"
|
| 535 |
-
]
|
| 536 |
-
},
|
| 537 |
-
"nvidia/rerank-qa-mistral-4b": {
|
| 538 |
-
"rating": 4,
|
| 539 |
-
"context_window": 4096,
|
| 540 |
-
"description": "Mistral-based QA reranker model.",
|
| 541 |
-
"strengths": [
|
| 542 |
-
"rerank",
|
| 543 |
-
"rag"
|
| 544 |
-
]
|
| 545 |
-
}
|
| 546 |
-
}
|
| 547 |
-
},
|
| 548 |
-
"vision": {
|
| 549 |
-
"primary": "meta/llama-3.2-90b-vision-instruct",
|
| 550 |
-
"fallback": [
|
| 551 |
-
"meta/llama-3.2-11b-vision-instruct",
|
| 552 |
-
"google/diffusiongemma-26b-a4b-it",
|
| 553 |
-
"google/paligemma",
|
| 554 |
-
"nvidia/nemotron-nano-12b-v2-vl",
|
| 555 |
-
"nvidia/llama-3.1-nemotron-nano-vl-8b-v1"
|
| 556 |
-
],
|
| 557 |
-
"models": {
|
| 558 |
-
"meta/llama-3.2-90b-vision-instruct": {
|
| 559 |
-
"rating": 5,
|
| 560 |
-
"context_window": 128000,
|
| 561 |
-
"description": "Meta Llama 3.2 90B Vision. High-end image and chart understanding.",
|
| 562 |
-
"strengths": [
|
| 563 |
-
"vision",
|
| 564 |
-
"chart_analysis"
|
| 565 |
-
]
|
| 566 |
-
},
|
| 567 |
-
"meta/llama-3.2-11b-vision-instruct": {
|
| 568 |
-
"rating": 4,
|
| 569 |
-
"context_window": 128000,
|
| 570 |
-
"description": "Meta Llama 3.2 11B Vision. Efficient image captioning and Q&A.",
|
| 571 |
-
"strengths": [
|
| 572 |
-
"vision",
|
| 573 |
-
"fast"
|
| 574 |
-
]
|
| 575 |
-
},
|
| 576 |
-
"google/diffusiongemma-26b-a4b-it": {
|
| 577 |
-
"rating": 4,
|
| 578 |
-
"context_window": 8192,
|
| 579 |
-
"description": "DiffusionGemma 26B multimodal visual instruction model.",
|
| 580 |
-
"strengths": [
|
| 581 |
-
"vision",
|
| 582 |
-
"diffusion"
|
| 583 |
-
]
|
| 584 |
-
},
|
| 585 |
-
"google/paligemma": {
|
| 586 |
-
"rating": 3,
|
| 587 |
-
"context_window": 4096,
|
| 588 |
-
"description": "Google PaliGemma multimodal model.",
|
| 589 |
-
"strengths": [
|
| 590 |
-
"vision",
|
| 591 |
-
"vqa"
|
| 592 |
-
]
|
| 593 |
-
},
|
| 594 |
-
"nvidia/nemotron-nano-12b-v2-vl": {
|
| 595 |
-
"rating": 4,
|
| 596 |
-
"context_window": 32768,
|
| 597 |
-
"description": "NVIDIA Nemotron Nano 12B VL model.",
|
| 598 |
-
"strengths": [
|
| 599 |
-
"vision",
|
| 600 |
-
"vl"
|
| 601 |
-
]
|
| 602 |
-
},
|
| 603 |
-
"nvidia/llama-3.1-nemotron-nano-vl-8b-v1": {
|
| 604 |
-
"rating": 3,
|
| 605 |
-
"context_window": 128000,
|
| 606 |
-
"description": "NVIDIA tuned Llama 3.1 Nano VL 8B.",
|
| 607 |
-
"strengths": [
|
| 608 |
-
"vision",
|
| 609 |
-
"fast"
|
| 610 |
-
]
|
| 611 |
-
}
|
| 612 |
-
}
|
| 613 |
-
},
|
| 614 |
-
"fast": {
|
| 615 |
-
"primary": "meta/llama-3.2-3b-instruct",
|
| 616 |
-
"fallback": [
|
| 617 |
-
"meta/llama-3.2-1b-instruct",
|
| 618 |
-
"nvidia/nemotron-mini-4b-instruct",
|
| 619 |
-
"google/gemma-2-2b-it"
|
| 620 |
-
],
|
| 621 |
-
"models": {
|
| 622 |
-
"meta/llama-3.2-3b-instruct": {
|
| 623 |
-
"rating": 4,
|
| 624 |
-
"context_window": 128000,
|
| 625 |
-
"description": "Llama 3.2 3B. Fast, lightweight edge-tier chatbot.",
|
| 626 |
-
"strengths": [
|
| 627 |
-
"fast",
|
| 628 |
-
"lightweight"
|
| 629 |
-
]
|
| 630 |
-
},
|
| 631 |
-
"meta/llama-3.2-1b-instruct": {
|
| 632 |
-
"rating": 3,
|
| 633 |
-
"context_window": 128000,
|
| 634 |
-
"description": "Llama 3.2 1B. Compact model for low token footprint.",
|
| 635 |
-
"strengths": [
|
| 636 |
-
"fast",
|
| 637 |
-
"lightweight"
|
| 638 |
-
]
|
| 639 |
-
},
|
| 640 |
-
"nvidia/nemotron-mini-4b-instruct": {
|
| 641 |
-
"rating": 3,
|
| 642 |
-
"context_window": 4096,
|
| 643 |
-
"description": "Nemotron Mini 4B instruction model.",
|
| 644 |
-
"strengths": [
|
| 645 |
-
"fast",
|
| 646 |
-
"compact"
|
| 647 |
-
]
|
| 648 |
-
},
|
| 649 |
-
"google/gemma-2-2b-it": {
|
| 650 |
-
"rating": 3,
|
| 651 |
-
"context_window": 8192,
|
| 652 |
-
"description": "Google Gemma-2 2B instruct.",
|
| 653 |
-
"strengths": [
|
| 654 |
-
"fast",
|
| 655 |
-
"compact"
|
| 656 |
-
]
|
| 657 |
-
}
|
| 658 |
-
}
|
| 659 |
-
},
|
| 660 |
-
"science": {
|
| 661 |
-
"primary": "nvidia/esmfold",
|
| 662 |
-
"fallback": [
|
| 663 |
-
"nvidia/esm2-650m"
|
| 664 |
-
],
|
| 665 |
-
"models": {
|
| 666 |
-
"nvidia/esmfold": {
|
| 667 |
-
"rating": 5,
|
| 668 |
-
"context_window": 1024,
|
| 669 |
-
"description": "ESMFold model for direct protein structure prediction from sequence.",
|
| 670 |
-
"strengths": [
|
| 671 |
-
"science",
|
| 672 |
-
"protein_folding"
|
| 673 |
-
]
|
| 674 |
-
},
|
| 675 |
-
"nvidia/esm2-650m": {
|
| 676 |
-
"rating": 4,
|
| 677 |
-
"context_window": 1024,
|
| 678 |
-
"description": "ESM-2 650M parameter protein language model.",
|
| 679 |
-
"strengths": [
|
| 680 |
-
"science",
|
| 681 |
-
"protein_language"
|
| 682 |
-
]
|
| 683 |
-
}
|
| 684 |
-
}
|
| 685 |
-
},
|
| 686 |
-
"multimodal_vision": {
|
| 687 |
-
"primary": "nvidia/cosmos3-nano",
|
| 688 |
-
"fallback": [
|
| 689 |
-
"nvidia/cosmos3-nano-reasoner",
|
| 690 |
-
"nvidia/ai-synthetic-video-detector",
|
| 691 |
-
"nvidia/active-speaker-detection",
|
| 692 |
-
"nvidia/ising-calibration-1-35b-a3b",
|
| 693 |
-
"nvidia/cosmos-transfer2.5-2b",
|
| 694 |
-
"nvidia/streampetr",
|
| 695 |
-
"nvidia/cosmos-transfer1-7b",
|
| 696 |
-
"nvidia/sparsedrive",
|
| 697 |
-
"nvidia/bevformer"
|
| 698 |
-
],
|
| 699 |
-
"models": {
|
| 700 |
-
"nvidia/cosmos3-nano": {
|
| 701 |
-
"rating": 5,
|
| 702 |
-
"context_window": 8192,
|
| 703 |
-
"description": "Cosmos-3 Nano spatial video model.",
|
| 704 |
-
"strengths": [
|
| 705 |
-
"video",
|
| 706 |
-
"spatial"
|
| 707 |
-
]
|
| 708 |
-
},
|
| 709 |
-
"nvidia/cosmos3-nano-reasoner": {
|
| 710 |
-
"rating": 5,
|
| 711 |
-
"context_window": 8192,
|
| 712 |
-
"description": "Cosmos-3 Nano reasoning visual agent.",
|
| 713 |
-
"strengths": [
|
| 714 |
-
"video",
|
| 715 |
-
"reasoning"
|
| 716 |
-
]
|
| 717 |
-
},
|
| 718 |
-
"nvidia/ai-synthetic-video-detector": {
|
| 719 |
-
"rating": 4,
|
| 720 |
-
"context_window": 4096,
|
| 721 |
-
"description": "Synthetic video generation detector.",
|
| 722 |
-
"strengths": [
|
| 723 |
-
"video",
|
| 724 |
-
"detection"
|
| 725 |
-
]
|
| 726 |
-
},
|
| 727 |
-
"nvidia/active-speaker-detection": {
|
| 728 |
-
"rating": 4,
|
| 729 |
-
"context_window": 4096,
|
| 730 |
-
"description": "Maxine active speaker detection.",
|
| 731 |
-
"strengths": [
|
| 732 |
-
"video",
|
| 733 |
-
"maxine"
|
| 734 |
-
]
|
| 735 |
-
},
|
| 736 |
-
"nvidia/ising-calibration-1-35b-a3b": {
|
| 737 |
-
"rating": 3,
|
| 738 |
-
"context_window": 4096,
|
| 739 |
-
"description": "Ising calibration vision model.",
|
| 740 |
-
"strengths": [
|
| 741 |
-
"video"
|
| 742 |
-
]
|
| 743 |
-
},
|
| 744 |
-
"nvidia/cosmos-transfer2.5-2b": {
|
| 745 |
-
"rating": 4,
|
| 746 |
-
"context_window": 4096,
|
| 747 |
-
"description": "Cosmos Transfer 2.5 2B video diffusion helper.",
|
| 748 |
-
"strengths": [
|
| 749 |
-
"video"
|
| 750 |
-
]
|
| 751 |
-
},
|
| 752 |
-
"nvidia/streampetr": {
|
| 753 |
-
"rating": 4,
|
| 754 |
-
"context_window": 4096,
|
| 755 |
-
"description": "StreamPETR 3D object detection for autonomous driving.",
|
| 756 |
-
"strengths": [
|
| 757 |
-
"driving",
|
| 758 |
-
"3d_detection"
|
| 759 |
-
]
|
| 760 |
-
},
|
| 761 |
-
"nvidia/cosmos-transfer1-7b": {
|
| 762 |
-
"rating": 4,
|
| 763 |
-
"context_window": 4096,
|
| 764 |
-
"description": "Cosmos Transfer 1 7B video model.",
|
| 765 |
-
"strengths": [
|
| 766 |
-
"video"
|
| 767 |
-
]
|
| 768 |
-
},
|
| 769 |
-
"nvidia/sparsedrive": {
|
| 770 |
-
"rating": 4,
|
| 771 |
-
"context_window": 4096,
|
| 772 |
-
"description": "SparseDrive planning for autonomous vehicles.",
|
| 773 |
-
"strengths": [
|
| 774 |
-
"driving"
|
| 775 |
-
]
|
| 776 |
-
},
|
| 777 |
-
"nvidia/bevformer": {
|
| 778 |
-
"rating": 4,
|
| 779 |
-
"context_window": 4096,
|
| 780 |
-
"description": "BEVFormer bird's-eye-view camera perception.",
|
| 781 |
-
"strengths": [
|
| 782 |
-
"driving",
|
| 783 |
-
"perception"
|
| 784 |
-
]
|
| 785 |
-
}
|
| 786 |
-
}
|
| 787 |
-
},
|
| 788 |
-
"audio": {
|
| 789 |
-
"primary": "nvidia/nemotron-voicechat",
|
| 790 |
-
"fallback": [
|
| 791 |
-
"nvidia/magpie-tts-zeroshot",
|
| 792 |
-
"nvidia/background-noise-removal",
|
| 793 |
-
"nvidia/studio-voice-enhance"
|
| 794 |
-
],
|
| 795 |
-
"models": {
|
| 796 |
-
"nvidia/nemotron-voicechat": {
|
| 797 |
-
"rating": 5,
|
| 798 |
-
"context_window": 4096,
|
| 799 |
-
"description": "Nemotron model optimized for voice-based conversational turn-taking.",
|
| 800 |
-
"strengths": [
|
| 801 |
-
"voice",
|
| 802 |
-
"chat"
|
| 803 |
-
]
|
| 804 |
-
},
|
| 805 |
-
"nvidia/magpie-tts-zeroshot": {
|
| 806 |
-
"rating": 4,
|
| 807 |
-
"context_window": 4096,
|
| 808 |
-
"description": "Magpie zero-shot Text-to-Speech synthesis.",
|
| 809 |
-
"strengths": [
|
| 810 |
-
"voice",
|
| 811 |
-
"tts"
|
| 812 |
-
]
|
| 813 |
-
},
|
| 814 |
-
"nvidia/background-noise-removal": {
|
| 815 |
-
"rating": 4,
|
| 816 |
-
"context_window": 4096,
|
| 817 |
-
"description": "Maxine background noise removal audio NIM.",
|
| 818 |
-
"strengths": [
|
| 819 |
-
"audio",
|
| 820 |
-
"maxine"
|
| 821 |
-
]
|
| 822 |
-
},
|
| 823 |
-
"nvidia/studio-voice-enhance": {
|
| 824 |
-
"rating": 4,
|
| 825 |
-
"context_window": 4096,
|
| 826 |
-
"description": "Maxine Studio voice enhance audio NIM.",
|
| 827 |
-
"strengths": [
|
| 828 |
-
"audio",
|
| 829 |
-
"maxine"
|
| 830 |
-
]
|
| 831 |
-
}
|
| 832 |
-
}
|
| 833 |
-
}
|
| 834 |
-
},
|
| 835 |
-
"routing_keywords": {
|
| 836 |
-
"coding": [
|
| 837 |
-
"code",
|
| 838 |
-
"python",
|
| 839 |
-
"function",
|
| 840 |
-
"class",
|
| 841 |
-
"debug",
|
| 842 |
-
"refactor",
|
| 843 |
-
"implement",
|
| 844 |
-
"syntax",
|
| 845 |
-
"algorithm",
|
| 846 |
-
"programming",
|
| 847 |
-
"script",
|
| 848 |
-
"module",
|
| 849 |
-
"bug",
|
| 850 |
-
"fix",
|
| 851 |
-
"rewrite",
|
| 852 |
-
"agent_workflow",
|
| 853 |
-
"agent"
|
| 854 |
-
],
|
| 855 |
-
"reasoning": [
|
| 856 |
-
"reason",
|
| 857 |
-
"analyze",
|
| 858 |
-
"plan",
|
| 859 |
-
"strategy",
|
| 860 |
-
"optimize",
|
| 861 |
-
"complex",
|
| 862 |
-
"math",
|
| 863 |
-
"calculate",
|
| 864 |
-
"evaluate",
|
| 865 |
-
"multi-step",
|
| 866 |
-
"explain",
|
| 867 |
-
"compare",
|
| 868 |
-
"decide",
|
| 869 |
-
"judge",
|
| 870 |
-
"score",
|
| 871 |
-
"audit",
|
| 872 |
-
"rate",
|
| 873 |
-
"hallucin",
|
| 874 |
-
"assess",
|
| 875 |
-
"review trajectory",
|
| 876 |
-
"llm-as-a-judge",
|
| 877 |
-
"finance",
|
| 878 |
-
"stock",
|
| 879 |
-
"trading",
|
| 880 |
-
"market",
|
| 881 |
-
"portfolio",
|
| 882 |
-
"financial",
|
| 883 |
-
"investment",
|
| 884 |
-
"equity",
|
| 885 |
-
"backtest",
|
| 886 |
-
"capital"
|
| 887 |
-
],
|
| 888 |
-
"memory": [
|
| 889 |
-
"summarize",
|
| 890 |
-
"memory",
|
| 891 |
-
"kb",
|
| 892 |
-
"knowledge",
|
| 893 |
-
"log",
|
| 894 |
-
"history",
|
| 895 |
-
"update memory",
|
| 896 |
-
"compress",
|
| 897 |
-
"recall",
|
| 898 |
-
"persist"
|
| 899 |
-
],
|
| 900 |
-
"chat": [
|
| 901 |
-
"chat",
|
| 902 |
-
"write",
|
| 903 |
-
"creative",
|
| 904 |
-
"document",
|
| 905 |
-
"report",
|
| 906 |
-
"essay",
|
| 907 |
-
"story",
|
| 908 |
-
"generate text"
|
| 909 |
-
],
|
| 910 |
-
"safety": [
|
| 911 |
-
"safety",
|
| 912 |
-
"harmful",
|
| 913 |
-
"pii",
|
| 914 |
-
"content moderation",
|
| 915 |
-
"guardrail",
|
| 916 |
-
"toxic",
|
| 917 |
-
"filter",
|
| 918 |
-
"censor"
|
| 919 |
-
],
|
| 920 |
-
"fast": [
|
| 921 |
-
"quick",
|
| 922 |
-
"fast",
|
| 923 |
-
"brief",
|
| 924 |
-
"short",
|
| 925 |
-
"classify",
|
| 926 |
-
"yes/no",
|
| 927 |
-
"simple",
|
| 928 |
-
"ping"
|
| 929 |
-
],
|
| 930 |
-
"science": [
|
| 931 |
-
"science",
|
| 932 |
-
"protein",
|
| 933 |
-
"esmfold",
|
| 934 |
-
"esm2",
|
| 935 |
-
"biology",
|
| 936 |
-
"molecule",
|
| 937 |
-
"structure",
|
| 938 |
-
"medical",
|
| 939 |
-
"health",
|
| 940 |
-
"clinical",
|
| 941 |
-
"patient",
|
| 942 |
-
"diagnosis",
|
| 943 |
-
"symptom",
|
| 944 |
-
"drug"
|
| 945 |
-
],
|
| 946 |
-
"vision": [
|
| 947 |
-
"image",
|
| 948 |
-
"chart",
|
| 949 |
-
"graph",
|
| 950 |
-
"visual",
|
| 951 |
-
"picture",
|
| 952 |
-
"plot",
|
| 953 |
-
"screenshot",
|
| 954 |
-
"vision"
|
| 955 |
-
],
|
| 956 |
-
"multimodal_vision": [
|
| 957 |
-
"cosmos",
|
| 958 |
-
"video",
|
| 959 |
-
"driving",
|
| 960 |
-
"bevformer",
|
| 961 |
-
"sparsedrive",
|
| 962 |
-
"calibration"
|
| 963 |
-
],
|
| 964 |
-
"audio": [
|
| 965 |
-
"voice",
|
| 966 |
-
"tts",
|
| 967 |
-
"speech",
|
| 968 |
-
"noise",
|
| 969 |
-
"audio",
|
| 970 |
-
"sound",
|
| 971 |
-
"enhance"
|
| 972 |
-
],
|
| 973 |
-
"translation": [
|
| 974 |
-
"translate",
|
| 975 |
-
"riva",
|
| 976 |
-
"language",
|
| 977 |
-
"bilingual",
|
| 978 |
-
"multilingual"
|
| 979 |
-
]
|
| 980 |
-
}
|
| 981 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/portfolio.py
DELETED
|
@@ -1,311 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.portfolio
|
| 3 |
-
======================
|
| 4 |
-
ICICI portfolio import (PortFolioEqtSummary.csv mapped through
|
| 5 |
-
NSEScripMaster.txt inside SecurityMaster.zip) and portfolio summary
|
| 6 |
-
calculation. Parity with v4.5 ``load_portfolio_data`` and
|
| 7 |
-
``update_portfolio_summary`` minus the UI coupling.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import logging
|
| 13 |
-
import zipfile
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
|
| 16 |
-
import pandas as pd
|
| 17 |
-
|
| 18 |
-
from ..config import Paths
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class PortfolioImportError(Exception):
|
| 22 |
-
"""Raised with a user-presentable message when the import cannot proceed."""
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def clean_symbol(sym: str) -> str:
|
| 26 |
-
"""Clean standard ticker symbols from common suffixes like .NS and -EQ."""
|
| 27 |
-
sym = str(sym).strip().upper()
|
| 28 |
-
if sym.endswith(".NS"):
|
| 29 |
-
sym = sym[:-3]
|
| 30 |
-
if sym.endswith("-EQ"):
|
| 31 |
-
sym = sym[:-3]
|
| 32 |
-
return sym
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def identify_broker(file_name: str, columns: list[str]) -> str:
|
| 36 |
-
"""Identify the broker based on filename and column header heuristics."""
|
| 37 |
-
filename_lower = file_name.lower()
|
| 38 |
-
normalized_cols = [str(c).strip().lower() for c in columns]
|
| 39 |
-
|
| 40 |
-
# Heuristics based on filename
|
| 41 |
-
if 'icici' in filename_lower or 'portfolioeqtsummary' in filename_lower:
|
| 42 |
-
return "ICICIDirect"
|
| 43 |
-
elif 'hdfc' in filename_lower:
|
| 44 |
-
return "HDFCSecurities"
|
| 45 |
-
elif 'groww' in filename_lower:
|
| 46 |
-
return "Groww"
|
| 47 |
-
elif 'angel' in filename_lower:
|
| 48 |
-
return "AngelOne"
|
| 49 |
-
elif 'upstox' in filename_lower:
|
| 50 |
-
return "Upstox"
|
| 51 |
-
elif 'zerodha' in filename_lower or 'holdings' in filename_lower:
|
| 52 |
-
return "Zerodha"
|
| 53 |
-
|
| 54 |
-
# Heuristics based on headers
|
| 55 |
-
if 'average cost price' in normalized_cols or 'stock symbol' in normalized_cols:
|
| 56 |
-
return "ICICIDirect"
|
| 57 |
-
elif 'avg. cost' in normalized_cols or 'qty.' in normalized_cols:
|
| 58 |
-
return "Zerodha"
|
| 59 |
-
elif 'trading_symbol' in normalized_cols or 'groww_locked_quantity' in normalized_cols:
|
| 60 |
-
return "Groww"
|
| 61 |
-
elif 'average acquisition price' in normalized_cols or 'scrip name' in normalized_cols:
|
| 62 |
-
return "AngelOne"
|
| 63 |
-
elif 'holding qty' in normalized_cols:
|
| 64 |
-
return "Upstox"
|
| 65 |
-
elif 'sip_indicator' in normalized_cols or 'mtf_indicator' in normalized_cols or 'company_name' in normalized_cols:
|
| 66 |
-
return "HDFCSecurities"
|
| 67 |
-
|
| 68 |
-
return "Generic"
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def identify_columns(df_columns: list[str]) -> tuple[str | None, str | None, str | None]:
|
| 72 |
-
"""Intelligently map columns to Symbol, Quantity, and Price using candidates list."""
|
| 73 |
-
normalized_cols = [str(c).strip().lower() for c in df_columns]
|
| 74 |
-
col_map = {str(c).strip().lower(): c for c in df_columns}
|
| 75 |
-
|
| 76 |
-
symbol_candidates = ['stock symbol', 'instrument', 'trading_symbol', 'symbol', 'trading symbol', 'scrip name', 'stock name', 'company name', 'isin name']
|
| 77 |
-
qty_candidates = ['qty', 'qty.', 'quantity', 'available qty.', 'holding qty', 'current balance', 'available qty', 'qty_available', 'holding quantity', 'quantity']
|
| 78 |
-
price_candidates = ['average cost price', 'avg. cost', 'average_price', 'average price', 'avg price', 'buy average', 'average cost', 'average acquisition price', 'avg cost', 'buy price', 'avg_price', 'avg_cost']
|
| 79 |
-
|
| 80 |
-
symbol_col = None
|
| 81 |
-
qty_col = None
|
| 82 |
-
price_col = None
|
| 83 |
-
|
| 84 |
-
for cand in symbol_candidates:
|
| 85 |
-
if cand in normalized_cols:
|
| 86 |
-
symbol_col = col_map[cand]
|
| 87 |
-
break
|
| 88 |
-
if not symbol_col:
|
| 89 |
-
for c in normalized_cols:
|
| 90 |
-
if any(cand in c for cand in ['symbol', 'instrument', 'scrip', 'company_name']):
|
| 91 |
-
symbol_col = col_map[c]
|
| 92 |
-
break
|
| 93 |
-
|
| 94 |
-
for cand in qty_candidates:
|
| 95 |
-
if cand in normalized_cols:
|
| 96 |
-
qty_col = col_map[cand]
|
| 97 |
-
break
|
| 98 |
-
if not qty_col:
|
| 99 |
-
for c in normalized_cols:
|
| 100 |
-
if any(cand in c for cand in ['qty', 'quantity', 'balance']):
|
| 101 |
-
qty_col = col_map[c]
|
| 102 |
-
break
|
| 103 |
-
|
| 104 |
-
for cand in price_candidates:
|
| 105 |
-
if cand in normalized_cols:
|
| 106 |
-
price_col = col_map[cand]
|
| 107 |
-
break
|
| 108 |
-
if not price_col:
|
| 109 |
-
for c in normalized_cols:
|
| 110 |
-
if any(cand in c for cand in ['avg', 'price', 'cost', 'buy']):
|
| 111 |
-
price_col = col_map[c]
|
| 112 |
-
break
|
| 113 |
-
|
| 114 |
-
return symbol_col, qty_col, price_col
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
def import_portfolio(file_path: Path | str | None, paths: Paths) -> tuple[list[dict], list[str], str]:
|
| 118 |
-
"""
|
| 119 |
-
Import holdings from a file path (or fallback to paths.portfolio_file).
|
| 120 |
-
Identifies the broker, parses columns, cleans symbols, and returns
|
| 121 |
-
(holdings, unmapped_symbols, broker_name).
|
| 122 |
-
"""
|
| 123 |
-
if file_path is None:
|
| 124 |
-
portfolio_file = paths.portfolio_file
|
| 125 |
-
# Check if CSV exists
|
| 126 |
-
csv_portfolio_file = Path(str(portfolio_file).replace('.parquet', '.csv'))
|
| 127 |
-
if csv_portfolio_file.exists():
|
| 128 |
-
try:
|
| 129 |
-
df_temp = pd.read_csv(csv_portfolio_file)
|
| 130 |
-
df_temp.to_parquet(portfolio_file, index=False)
|
| 131 |
-
csv_portfolio_file.unlink()
|
| 132 |
-
logging.info(f"Auto-converted portfolio CSV '{csv_portfolio_file}' to Parquet.")
|
| 133 |
-
except Exception as e:
|
| 134 |
-
logging.warning(f"Failed to auto-convert portfolio CSV: {e}")
|
| 135 |
-
file_path = portfolio_file
|
| 136 |
-
elif portfolio_file.exists():
|
| 137 |
-
file_path = portfolio_file
|
| 138 |
-
else:
|
| 139 |
-
raise PortfolioImportError(f"Portfolio file not found: {portfolio_file}")
|
| 140 |
-
|
| 141 |
-
file_path = Path(file_path)
|
| 142 |
-
is_parquet = file_path.suffix.lower() == '.parquet'
|
| 143 |
-
|
| 144 |
-
try:
|
| 145 |
-
if is_parquet:
|
| 146 |
-
df = pd.read_parquet(file_path)
|
| 147 |
-
else:
|
| 148 |
-
df = pd.read_csv(file_path)
|
| 149 |
-
except Exception as e:
|
| 150 |
-
raise PortfolioImportError(f"Failed to read portfolio file: {e}")
|
| 151 |
-
|
| 152 |
-
if df.empty:
|
| 153 |
-
raise PortfolioImportError("One of the CSV files is empty.")
|
| 154 |
-
|
| 155 |
-
# Strip columns
|
| 156 |
-
df.columns = df.columns.str.strip().str.replace('"', '')
|
| 157 |
-
|
| 158 |
-
# Check if this is a previously standardized file by us
|
| 159 |
-
if 'Broker' in df.columns and 'SYMBOL' in df.columns:
|
| 160 |
-
broker_name = df['Broker'].iloc[0] if not df['Broker'].empty else "Generic"
|
| 161 |
-
holdings = []
|
| 162 |
-
for _, row in df.iterrows():
|
| 163 |
-
holdings.append({
|
| 164 |
-
"SYMBOL": str(row['SYMBOL']),
|
| 165 |
-
"Quantity": float(row['Quantity']) if pd.notna(row['Quantity']) else 0.0,
|
| 166 |
-
"Price": float(row['Price']) if pd.notna(row['Price']) else 0.0,
|
| 167 |
-
})
|
| 168 |
-
return holdings, [], broker_name
|
| 169 |
-
|
| 170 |
-
broker_name = identify_broker(file_path.name, df.columns)
|
| 171 |
-
|
| 172 |
-
symbol_col, qty_col, price_col = identify_columns(df.columns)
|
| 173 |
-
if not symbol_col or not qty_col or not price_col:
|
| 174 |
-
if len(df.columns) >= 3:
|
| 175 |
-
symbol_col = df.columns[0]
|
| 176 |
-
qty_col = df.columns[1]
|
| 177 |
-
price_col = df.columns[2]
|
| 178 |
-
else:
|
| 179 |
-
raise PortfolioImportError(
|
| 180 |
-
f"Could not map columns for broker '{broker_name}'. Expected columns for Symbol, Quantity, and Price."
|
| 181 |
-
)
|
| 182 |
-
|
| 183 |
-
holdings = []
|
| 184 |
-
unmapped = []
|
| 185 |
-
|
| 186 |
-
if broker_name == "ICICIDirect":
|
| 187 |
-
# Load ICICI Direct specific mapping
|
| 188 |
-
mapping_zip_file = paths.security_master_zip
|
| 189 |
-
if not mapping_zip_file.exists():
|
| 190 |
-
raise PortfolioImportError(f"Mapping zip file not found: {mapping_zip_file}")
|
| 191 |
-
|
| 192 |
-
mapping_parquet = paths.data_dir / 'NSEScripMaster.parquet'
|
| 193 |
-
need_convert = True
|
| 194 |
-
if mapping_parquet.exists():
|
| 195 |
-
if mapping_parquet.stat().st_mtime > mapping_zip_file.stat().st_mtime:
|
| 196 |
-
need_convert = False
|
| 197 |
-
|
| 198 |
-
if need_convert:
|
| 199 |
-
with zipfile.ZipFile(mapping_zip_file, 'r') as z:
|
| 200 |
-
if 'NSEScripMaster.txt' not in z.namelist():
|
| 201 |
-
raise PortfolioImportError("NSEScripMaster.txt not found in SecurityMaster.zip")
|
| 202 |
-
z.extract('NSEScripMaster.txt', path=paths.data_dir)
|
| 203 |
-
mapping_txt = paths.data_dir / 'NSEScripMaster.txt'
|
| 204 |
-
if not mapping_txt.exists():
|
| 205 |
-
raise PortfolioImportError(f"Mapping file not found: {mapping_txt}")
|
| 206 |
-
try:
|
| 207 |
-
mapping_df = pd.read_csv(mapping_txt)
|
| 208 |
-
mapping_df.to_parquet(mapping_parquet, index=False)
|
| 209 |
-
mapping_txt.unlink()
|
| 210 |
-
logging.info("Auto-converted mapping txt to NSEScripMaster.parquet.")
|
| 211 |
-
except Exception as e:
|
| 212 |
-
raise PortfolioImportError(f"Failed to convert mapping CSV to Parquet: {e}")
|
| 213 |
-
|
| 214 |
-
try:
|
| 215 |
-
mapping_df = pd.read_parquet(mapping_parquet)
|
| 216 |
-
except Exception as e:
|
| 217 |
-
raise PortfolioImportError(f"Failed to read mapping Parquet: {e}")
|
| 218 |
-
|
| 219 |
-
mapping_df.columns = mapping_df.columns.str.strip().str.replace('"', '')
|
| 220 |
-
|
| 221 |
-
try:
|
| 222 |
-
icici_to_nse = dict(zip(mapping_df['ShortName'].astype(str).str.strip(), mapping_df['ExchangeCode'].astype(str).str.strip()))
|
| 223 |
-
except KeyError as ke:
|
| 224 |
-
raise PortfolioImportError(f"Missing expected column in mapping file: {ke}")
|
| 225 |
-
|
| 226 |
-
for _, row in df.iterrows():
|
| 227 |
-
raw_sym = str(row[symbol_col]).strip()
|
| 228 |
-
nse_sym = icici_to_nse.get(raw_sym, None)
|
| 229 |
-
if nse_sym:
|
| 230 |
-
try:
|
| 231 |
-
qty = float(row[qty_col]) if pd.notna(row[qty_col]) else 0.0
|
| 232 |
-
price = float(row[price_col]) if pd.notna(row[price_col]) else 0.0
|
| 233 |
-
holdings.append({
|
| 234 |
-
"SYMBOL": nse_sym,
|
| 235 |
-
"Quantity": qty,
|
| 236 |
-
"Price": price
|
| 237 |
-
})
|
| 238 |
-
except (ValueError, KeyError) as e:
|
| 239 |
-
logging.warning(f"Failed to parse row for {raw_sym}: {e}")
|
| 240 |
-
else:
|
| 241 |
-
unmapped.append(raw_sym)
|
| 242 |
-
else:
|
| 243 |
-
# Standard cleaning for Zerodha, Groww, AngelOne, Upstox, HDFCSecurities, Generic
|
| 244 |
-
for _, row in df.iterrows():
|
| 245 |
-
raw_sym = str(row[symbol_col]).strip()
|
| 246 |
-
nse_sym = clean_symbol(raw_sym)
|
| 247 |
-
if nse_sym:
|
| 248 |
-
try:
|
| 249 |
-
qty = float(row[qty_col]) if pd.notna(row[qty_col]) else 0.0
|
| 250 |
-
price = float(row[price_col]) if pd.notna(row[price_col]) else 0.0
|
| 251 |
-
holdings.append({
|
| 252 |
-
"SYMBOL": nse_sym,
|
| 253 |
-
"Quantity": qty,
|
| 254 |
-
"Price": price
|
| 255 |
-
})
|
| 256 |
-
except (ValueError, KeyError) as e:
|
| 257 |
-
logging.warning(f"Failed to parse row for {raw_sym}: {e}")
|
| 258 |
-
else:
|
| 259 |
-
unmapped.append(raw_sym)
|
| 260 |
-
|
| 261 |
-
return holdings, unmapped, broker_name
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
def import_icici_portfolio(paths: Paths) -> tuple[list[dict], list[str]]:
|
| 265 |
-
"""Thin backwards-compatible wrapper calling import_portfolio."""
|
| 266 |
-
holdings, unmapped, _ = import_portfolio(None, paths)
|
| 267 |
-
return holdings, unmapped
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
def compute_portfolio_summary(cells: list[dict]) -> dict:
|
| 272 |
-
"""
|
| 273 |
-
Aggregate the portfolio metrics displayed in the top bar.
|
| 274 |
-
|
| 275 |
-
Each cell dict must provide: Quantity, Price (cost), current_cmp,
|
| 276 |
-
prev_close (0.0 when unknown — the caller applies the v4.5 fallback of
|
| 277 |
-
prev_close := current_cmp when no historic row exists).
|
| 278 |
-
"""
|
| 279 |
-
total_invested = 0.0
|
| 280 |
-
total_current_value = 0.0
|
| 281 |
-
total_day_gain = 0.0
|
| 282 |
-
total_previous_close_value = 0.0
|
| 283 |
-
for c in cells:
|
| 284 |
-
qty = float(c.get('Quantity', 0.0) or 0.0)
|
| 285 |
-
cost_price = float(c.get('Price', 0.0) or 0.0)
|
| 286 |
-
current_cmp = float(c.get('current_cmp', 0.0) or 0.0)
|
| 287 |
-
prev_close = float(c.get('prev_close', 0.0) or 0.0)
|
| 288 |
-
if prev_close == 0.0 and current_cmp != 0.0:
|
| 289 |
-
prev_close = current_cmp
|
| 290 |
-
total_invested += qty * cost_price
|
| 291 |
-
total_current_value += qty * current_cmp
|
| 292 |
-
if prev_close > 0 and current_cmp > 0:
|
| 293 |
-
total_day_gain += qty * (current_cmp - prev_close)
|
| 294 |
-
total_previous_close_value += qty * prev_close
|
| 295 |
-
absolute_returns = total_current_value - total_invested
|
| 296 |
-
day_gain_pct = (
|
| 297 |
-
total_day_gain / total_previous_close_value * 100
|
| 298 |
-
if total_previous_close_value != 0 else 0.0
|
| 299 |
-
)
|
| 300 |
-
abs_returns_pct = (
|
| 301 |
-
absolute_returns / total_invested * 100
|
| 302 |
-
if total_invested != 0 else 0.0
|
| 303 |
-
)
|
| 304 |
-
return {
|
| 305 |
-
"invested": total_invested,
|
| 306 |
-
"current_value": total_current_value,
|
| 307 |
-
"day_gain_abs": total_day_gain,
|
| 308 |
-
"day_gain_pct": day_gain_pct,
|
| 309 |
-
"abs_returns_abs": absolute_returns,
|
| 310 |
-
"abs_returns_pct": abs_returns_pct,
|
| 311 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/recommender_bridge.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.recommender_bridge
|
| 3 |
-
===============================
|
| 4 |
-
Bridge to the external ``recommender.SymbolRecommender`` module (LSTM
|
| 5 |
-
buy/sell). v4.5 hard-imported ``recommender`` (and TensorFlow transitively)
|
| 6 |
-
at module import time, so the entire dashboard failed to start when the
|
| 7 |
-
optional deep-learning stack was absent (AUDIT FIX H-9). This bridge imports
|
| 8 |
-
lazily and degrades to ``N/A`` recommendations when unavailable, while
|
| 9 |
-
preserving full behaviour when the module is present.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
from __future__ import annotations
|
| 13 |
-
|
| 14 |
-
import logging
|
| 15 |
-
from pathlib import Path
|
| 16 |
-
|
| 17 |
-
from ..config import Recommendation
|
| 18 |
-
|
| 19 |
-
_MIN_ROWS_FOR_PREDICTION = 61 # parity with v4.5 run_buy_sell_recommendation
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _load_recommender(recommender_dir: Path):
|
| 23 |
-
try:
|
| 24 |
-
import sys
|
| 25 |
-
nsereco_path = str(Path(__file__).resolve().parents[2] / 'nsereco')
|
| 26 |
-
if nsereco_path not in sys.path:
|
| 27 |
-
sys.path.append(nsereco_path)
|
| 28 |
-
from recommender import SymbolRecommender # noqa: PLC0415
|
| 29 |
-
return SymbolRecommender(recommender_dir)
|
| 30 |
-
except Exception as e:
|
| 31 |
-
logging.warning(f"SymbolRecommender unavailable: {e}")
|
| 32 |
-
return None
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def recommender_available(recommender_dir: Path) -> bool:
|
| 36 |
-
return _load_recommender(recommender_dir) is not None
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def run_recommendations(recommender_dir: Path, symbols: list[str],
|
| 40 |
-
history_lookup) -> list[dict]:
|
| 41 |
-
"""
|
| 42 |
-
Generate buy/sell predictions for the given symbols.
|
| 43 |
-
|
| 44 |
-
``history_lookup(symbol)`` returns the symbol's historical DataFrame
|
| 45 |
-
sorted ascending by TRAN_DATE.
|
| 46 |
-
|
| 47 |
-
Returns a list of {'symbol','action','confidence'} sorted by confidence
|
| 48 |
-
descending — the same records v4.5 showed in the results dialog, plus
|
| 49 |
-
one entry per skipped symbol carrying Recommendation.NA.
|
| 50 |
-
"""
|
| 51 |
-
recommender = _load_recommender(recommender_dir)
|
| 52 |
-
results: list[dict] = []
|
| 53 |
-
for symbol in symbols:
|
| 54 |
-
model_file = Path(recommender_dir) / f"{symbol}.h5"
|
| 55 |
-
if recommender is None or not model_file.exists():
|
| 56 |
-
if recommender is not None:
|
| 57 |
-
logging.warning(f"[{symbol}] No trained model found at {model_file}.")
|
| 58 |
-
results.append({
|
| 59 |
-
"symbol": symbol,
|
| 60 |
-
"action": Recommendation.NA.value,
|
| 61 |
-
"confidence": 0.0,
|
| 62 |
-
"predicted": False,
|
| 63 |
-
})
|
| 64 |
-
continue
|
| 65 |
-
symbol_df = history_lookup(symbol)
|
| 66 |
-
if symbol_df is not None and not symbol_df.empty \
|
| 67 |
-
and len(symbol_df) >= _MIN_ROWS_FOR_PREDICTION:
|
| 68 |
-
tracker = getattr(recommender, 'tracker_data', {}) or {}
|
| 69 |
-
entry = tracker.get(symbol, {})
|
| 70 |
-
confidence = entry.get('confidence_score', 0.0)
|
| 71 |
-
action = entry.get('recommendation', Recommendation.NA)
|
| 72 |
-
action = action.value if isinstance(action, Recommendation) else str(action)
|
| 73 |
-
results.append({
|
| 74 |
-
"symbol": symbol,
|
| 75 |
-
"action": action,
|
| 76 |
-
"confidence": float(confidence),
|
| 77 |
-
"predicted": True,
|
| 78 |
-
})
|
| 79 |
-
else:
|
| 80 |
-
logging.warning(f"[{symbol}] Skipping prediction: insufficient data.")
|
| 81 |
-
results.append({
|
| 82 |
-
"symbol": symbol,
|
| 83 |
-
"action": Recommendation.NA.value,
|
| 84 |
-
"confidence": 0.0,
|
| 85 |
-
"predicted": False,
|
| 86 |
-
})
|
| 87 |
-
results.sort(key=lambda r: r["confidence"], reverse=True)
|
| 88 |
-
return results
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def train_all_models(recommender_dir: Path, symbols: list[str],
|
| 92 |
-
history_lookup) -> dict:
|
| 93 |
-
"""
|
| 94 |
-
Train LSTM models for all symbols. v4.5 delegated to
|
| 95 |
-
``SymbolRecommender.train_all_async(symbols, lookup, tk_root)``; here the
|
| 96 |
-
web job system supplies the asynchrony, so the synchronous variant is
|
| 97 |
-
preferred when exposed by the module, falling back to the async API with
|
| 98 |
-
a ``None`` UI handle.
|
| 99 |
-
"""
|
| 100 |
-
recommender = _load_recommender(recommender_dir)
|
| 101 |
-
if recommender is None:
|
| 102 |
-
raise RuntimeError(
|
| 103 |
-
"SymbolRecommender module (and its TensorFlow dependency) is not "
|
| 104 |
-
"installed; deep-learning training is unavailable."
|
| 105 |
-
)
|
| 106 |
-
if hasattr(recommender, 'train_all'):
|
| 107 |
-
recommender.train_all(symbols, history_lookup)
|
| 108 |
-
else:
|
| 109 |
-
recommender.train_all_async(symbols, history_lookup, None)
|
| 110 |
-
return {"trained_symbols": len(symbols)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/stock_data.py
DELETED
|
@@ -1,504 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.stock_data
|
| 3 |
-
=======================
|
| 4 |
-
Headless data layer (no UI coupling). Owns the historical DataFrame and all
|
| 5 |
-
derived analytics. Functional parity with v4.5 ``StockData`` minus the
|
| 6 |
-
Tkinter dialogs, which are replaced by exceptions/return values surfaced to
|
| 7 |
-
the web layer.
|
| 8 |
-
|
| 9 |
-
Audit fixes applied here:
|
| 10 |
-
P-4 ``get_data_for_date`` no longer performs an O(rows) boolean scan of
|
| 11 |
-
the date slice per column assignment per symbol (O(S²) in v4.5);
|
| 12 |
-
results are accumulated per symbol and joined once.
|
| 13 |
-
P-5 The 5-day volume-change scan (30 per-day full-column comparisons per
|
| 14 |
-
symbol in v4.5) is replaced by a single windowed slice per symbol.
|
| 15 |
-
A-2 ``df['DAILY_RETURN'].fillna(0, inplace=True)`` (chained-assignment,
|
| 16 |
-
removed in pandas 3.x) replaced with safe column assignment.
|
| 17 |
-
P-6 The parquet cache is only rewritten when the source CSV has changed
|
| 18 |
-
(mtime check) instead of on every load.
|
| 19 |
-
D-1 Data-layer methods no longer call ``messagebox``; errors are logged
|
| 20 |
-
and raised/returned for the caller to present.
|
| 21 |
-
T-1 A re-entrant lock guards DataFrame replacement so background jobs and
|
| 22 |
-
request handlers cannot observe a half-swapped frame.
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
from __future__ import annotations
|
| 26 |
-
|
| 27 |
-
import gc
|
| 28 |
-
import json
|
| 29 |
-
import logging
|
| 30 |
-
import threading
|
| 31 |
-
from datetime import datetime, timedelta
|
| 32 |
-
from pathlib import Path
|
| 33 |
-
|
| 34 |
-
import numpy as np
|
| 35 |
-
import pandas as pd
|
| 36 |
-
|
| 37 |
-
from ..config import (ANALYSIS_LIMIT, CSV_COLUMNS, CSV_DTYPES,
|
| 38 |
-
DATA_RETENTION_DAYS, Paths)
|
| 39 |
-
from ..logging_utils import elapsed_time_logger
|
| 40 |
-
from .live_fetch import LiveFetcher
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
class StockData:
|
| 44 |
-
"""
|
| 45 |
-
Manages all data I/O, historical analysis, and real-time scraping.
|
| 46 |
-
|
| 47 |
-
Responsibilities (unchanged from v4.5):
|
| 48 |
-
- Loading / caching historical CSV data
|
| 49 |
-
- Computing returns, volatility, volume changes
|
| 50 |
-
- Real-time fetching (delegated to LiveFetcher)
|
| 51 |
-
- Watchlist JSON read/write helpers (delegated to watchlists module)
|
| 52 |
-
- ETF exclusion list management
|
| 53 |
-
- Moneycontrol score data access
|
| 54 |
-
"""
|
| 55 |
-
|
| 56 |
-
def __init__(self, paths: Paths):
|
| 57 |
-
self.paths = paths
|
| 58 |
-
self.paths.ensure_dirs()
|
| 59 |
-
self._lock = threading.RLock()
|
| 60 |
-
self.df: pd.DataFrame = pd.DataFrame()
|
| 61 |
-
self.last_date = None
|
| 62 |
-
self.two_years_ago = None
|
| 63 |
-
self.etf_symbols = self.load_etf_symbols()
|
| 64 |
-
self.fetcher = LiveFetcher(prev_volume_lookup=self.previous_day_volume)
|
| 65 |
-
self.load_data()
|
| 66 |
-
|
| 67 |
-
# ------------------------------------------------------------------
|
| 68 |
-
# ETF Management
|
| 69 |
-
# ------------------------------------------------------------------
|
| 70 |
-
|
| 71 |
-
def load_etf_symbols(self) -> set:
|
| 72 |
-
"""Load ETF symbols from the JSON exclusion list file."""
|
| 73 |
-
try:
|
| 74 |
-
if self.paths.etf_list_file.exists():
|
| 75 |
-
with open(self.paths.etf_list_file, 'r') as f:
|
| 76 |
-
return set(json.load(f))
|
| 77 |
-
logging.warning(
|
| 78 |
-
f"ETF list file not found: {self.paths.etf_list_file}. "
|
| 79 |
-
"No ETFs will be excluded."
|
| 80 |
-
)
|
| 81 |
-
return set()
|
| 82 |
-
except Exception as e:
|
| 83 |
-
logging.error(
|
| 84 |
-
f"Error loading ETF symbols from {self.paths.etf_list_file}: {e}"
|
| 85 |
-
)
|
| 86 |
-
return set()
|
| 87 |
-
|
| 88 |
-
# ------------------------------------------------------------------
|
| 89 |
-
# Symbol Utilities
|
| 90 |
-
# ------------------------------------------------------------------
|
| 91 |
-
|
| 92 |
-
def get_all_symbols_from_df(self) -> list[str]:
|
| 93 |
-
"""Return list of all unique stock symbols in the loaded DataFrame."""
|
| 94 |
-
with self._lock:
|
| 95 |
-
if not self.df.empty:
|
| 96 |
-
return self.df['SYMBOL'].unique().tolist()
|
| 97 |
-
return []
|
| 98 |
-
|
| 99 |
-
def symbol_history(self, symbol: str) -> pd.DataFrame:
|
| 100 |
-
"""All rows for a symbol sorted ascending by TRAN_DATE."""
|
| 101 |
-
with self._lock:
|
| 102 |
-
return self.df[self.df['SYMBOL'] == symbol].sort_values('TRAN_DATE').copy()
|
| 103 |
-
|
| 104 |
-
def previous_day_volume(self, symbol: str) -> float:
|
| 105 |
-
"""Latest historic NET_TRDQTY strictly before today (for live fetch)."""
|
| 106 |
-
with self._lock:
|
| 107 |
-
df = self.df
|
| 108 |
-
if df.empty:
|
| 109 |
-
return 0.0
|
| 110 |
-
rows = df[df['SYMBOL'] == symbol]
|
| 111 |
-
today = datetime.now().date()
|
| 112 |
-
prev = rows[rows['TRAN_DATE'].dt.date < today]
|
| 113 |
-
if prev.empty:
|
| 114 |
-
logging.warning(f"No previous day's historic volume found for {symbol}.")
|
| 115 |
-
return 0.0
|
| 116 |
-
return float(prev.sort_values('TRAN_DATE').iloc[-1]['NET_TRDQTY'])
|
| 117 |
-
|
| 118 |
-
# ------------------------------------------------------------------
|
| 119 |
-
# Real-time delegation (API parity with v4.5)
|
| 120 |
-
# ------------------------------------------------------------------
|
| 121 |
-
|
| 122 |
-
def fetch_real_time_data(self, symbols, preferred_source="Google"):
|
| 123 |
-
return self.fetcher.fetch_real_time_data(symbols, preferred_source)
|
| 124 |
-
|
| 125 |
-
# ------------------------------------------------------------------
|
| 126 |
-
# Data Loading
|
| 127 |
-
# ------------------------------------------------------------------
|
| 128 |
-
|
| 129 |
-
@elapsed_time_logger("Loading stock data...")
|
| 130 |
-
def load_data(self) -> None:
|
| 131 |
-
"""
|
| 132 |
-
Load nse_processed_data.parquet, trim to the last 2 years,
|
| 133 |
-
compute DAILY_RETURN and VOLATILITY, and cache as parquet.
|
| 134 |
-
"""
|
| 135 |
-
try:
|
| 136 |
-
pq_path = self.paths.data_dir / 'nse_processed_data.parquet'
|
| 137 |
-
|
| 138 |
-
if pq_path.exists():
|
| 139 |
-
logging.info(f"Loading data from Parquet: {pq_path}")
|
| 140 |
-
df = pd.read_parquet(pq_path, columns=CSV_COLUMNS)
|
| 141 |
-
# Ensure TRAN_DATE is datetime type
|
| 142 |
-
if not pd.api.types.is_datetime64_any_dtype(df['TRAN_DATE']):
|
| 143 |
-
df['TRAN_DATE'] = pd.to_datetime(df['TRAN_DATE'])
|
| 144 |
-
else:
|
| 145 |
-
raise FileNotFoundError(f"Parquet file ({pq_path}) not found.")
|
| 146 |
-
|
| 147 |
-
last_date = df['TRAN_DATE'].max()
|
| 148 |
-
two_years_ago = last_date - timedelta(days=DATA_RETENTION_DAYS)
|
| 149 |
-
df = df[df['TRAN_DATE'] >= two_years_ago].copy()
|
| 150 |
-
df = df.sort_values(by=['SYMBOL', 'TRAN_DATE']).reset_index(drop=True)
|
| 151 |
-
|
| 152 |
-
# Daily return (% change in closing price per symbol)
|
| 153 |
-
df['DAILY_RETURN'] = (
|
| 154 |
-
df.groupby('SYMBOL', observed=True)['CLOSE_PRICE'].pct_change() * 100
|
| 155 |
-
)
|
| 156 |
-
df['DAILY_RETURN'] = df['DAILY_RETURN'].fillna(0) # AUDIT FIX A-2
|
| 157 |
-
|
| 158 |
-
# 30-day rolling volatility (std of daily returns)
|
| 159 |
-
df['VOLATILITY'] = df.groupby('SYMBOL', observed=True)['DAILY_RETURN'].transform(
|
| 160 |
-
lambda x: x.rolling(window=30, min_periods=1).std()
|
| 161 |
-
)
|
| 162 |
-
df['VOLATILITY'] = df['VOLATILITY'].fillna(0)
|
| 163 |
-
|
| 164 |
-
with self._lock:
|
| 165 |
-
self.df = df
|
| 166 |
-
self.last_date = last_date
|
| 167 |
-
self.two_years_ago = two_years_ago
|
| 168 |
-
|
| 169 |
-
# Keep extract file updated for compatibility
|
| 170 |
-
try:
|
| 171 |
-
pq = self.paths.extract_file
|
| 172 |
-
if not pq.exists() or pq.stat().st_mtime < pq_path.stat().st_mtime:
|
| 173 |
-
df.to_parquet(pq, index=False)
|
| 174 |
-
except Exception as e:
|
| 175 |
-
logging.warning(f"Parquet cache write skipped: {e}")
|
| 176 |
-
|
| 177 |
-
logging.info(
|
| 178 |
-
f"Data loaded from {two_years_ago} with "
|
| 179 |
-
"DAILY_RETURN and VOLATILITY calculated."
|
| 180 |
-
)
|
| 181 |
-
gc.collect()
|
| 182 |
-
except Exception as e:
|
| 183 |
-
logging.error(f"Failed to load data: {e}")
|
| 184 |
-
raise
|
| 185 |
-
|
| 186 |
-
# ------------------------------------------------------------------
|
| 187 |
-
# Per-date enrichment
|
| 188 |
-
# ------------------------------------------------------------------
|
| 189 |
-
|
| 190 |
-
@elapsed_time_logger("Loading date data...")
|
| 191 |
-
def get_data_for_date(self, date) -> pd.DataFrame:
|
| 192 |
-
"""
|
| 193 |
-
Return enriched DataFrame for a specific trading date, including:
|
| 194 |
-
WEEK_CHANGE, MONTH_CHANGE, THREE_MONTH_CHANGE, TWO_YEAR_CHANGE,
|
| 195 |
-
PRICE_HISTORY, VOLUME_HISTORY, AVG_VOLUME, VOLUME_CHANGES.
|
| 196 |
-
Raises ValueError if no rows exist for the date.
|
| 197 |
-
"""
|
| 198 |
-
logging.info(f"Getting data for {date}")
|
| 199 |
-
if isinstance(date, datetime):
|
| 200 |
-
date = date.date()
|
| 201 |
-
if isinstance(date, pd.Timestamp):
|
| 202 |
-
date = date.date()
|
| 203 |
-
with self._lock:
|
| 204 |
-
df = self.df
|
| 205 |
-
date_data = df[df['TRAN_DATE'].dt.date == date].copy()
|
| 206 |
-
if date_data.empty:
|
| 207 |
-
raise ValueError(f"No data for {date}")
|
| 208 |
-
|
| 209 |
-
grouped = df.groupby('SYMBOL', observed=True)
|
| 210 |
-
results: dict[str, dict] = {}
|
| 211 |
-
symbol_errors: list[tuple[str, str]] = []
|
| 212 |
-
|
| 213 |
-
for symbol in date_data['SYMBOL'].unique():
|
| 214 |
-
try:
|
| 215 |
-
try:
|
| 216 |
-
symbol_data = grouped.get_group(symbol)
|
| 217 |
-
except KeyError:
|
| 218 |
-
raise ValueError(f"No historical data for symbol {symbol}")
|
| 219 |
-
if symbol_data.empty:
|
| 220 |
-
raise ValueError(f"No historical data for symbol {symbol}")
|
| 221 |
-
|
| 222 |
-
curr_date = date_data.loc[
|
| 223 |
-
date_data['SYMBOL'] == symbol, 'TRAN_DATE'
|
| 224 |
-
].iloc[0]
|
| 225 |
-
dates = symbol_data['TRAN_DATE']
|
| 226 |
-
closes = symbol_data['CLOSE_PRICE']
|
| 227 |
-
res: dict = {}
|
| 228 |
-
|
| 229 |
-
def window_change(days_back: int) -> float:
|
| 230 |
-
start = curr_date - timedelta(days=days_back)
|
| 231 |
-
prices = closes[(dates <= curr_date) & (dates >= start)]
|
| 232 |
-
if prices.empty:
|
| 233 |
-
return 0.0
|
| 234 |
-
return float((prices.iloc[-1] / prices.iloc[0] - 1) * 100)
|
| 235 |
-
|
| 236 |
-
res['WEEK_CHANGE'] = window_change(7)
|
| 237 |
-
res['MONTH_CHANGE'] = window_change(30)
|
| 238 |
-
res['THREE_MONTH_CHANGE'] = window_change(90)
|
| 239 |
-
|
| 240 |
-
# --- 2-year window: change, histories, average volume ---
|
| 241 |
-
two_years_ago = curr_date - timedelta(days=365 * 2)
|
| 242 |
-
mask_2y = (dates <= curr_date) & (dates >= two_years_ago)
|
| 243 |
-
two_year_data = symbol_data[mask_2y]
|
| 244 |
-
if not two_year_data.empty:
|
| 245 |
-
res['TWO_YEAR_CHANGE'] = float(
|
| 246 |
-
(two_year_data['CLOSE_PRICE'].iloc[-1] /
|
| 247 |
-
two_year_data['CLOSE_PRICE'].iloc[0] - 1) * 100
|
| 248 |
-
)
|
| 249 |
-
res['PRICE_HISTORY'] = (
|
| 250 |
-
two_year_data[['TRAN_DATE', 'CLOSE_PRICE']].values.tolist()
|
| 251 |
-
)
|
| 252 |
-
res['VOLUME_HISTORY'] = (
|
| 253 |
-
two_year_data[['TRAN_DATE', 'NET_TRDQTY']].values.tolist()
|
| 254 |
-
)
|
| 255 |
-
res['AVG_VOLUME'] = float(two_year_data['NET_TRDQTY'].mean())
|
| 256 |
-
else:
|
| 257 |
-
res['TWO_YEAR_CHANGE'] = 0.0
|
| 258 |
-
res['PRICE_HISTORY'] = None
|
| 259 |
-
res['VOLUME_HISTORY'] = None
|
| 260 |
-
res['AVG_VOLUME'] = 0.0
|
| 261 |
-
|
| 262 |
-
# --- 5-day volume changes (last 6 trading days within a
|
| 263 |
-
# 30-calendar-day lookback, parity with v4.5) ---
|
| 264 |
-
lookback_start = curr_date - timedelta(days=29)
|
| 265 |
-
recent = symbol_data[
|
| 266 |
-
(dates <= curr_date) & (dates > lookback_start - timedelta(days=1))
|
| 267 |
-
]
|
| 268 |
-
volumes_asc = recent['NET_TRDQTY'].tail(6).astype(float).tolist()
|
| 269 |
-
while len(volumes_asc) < 6:
|
| 270 |
-
volumes_asc.insert(0, 0.0)
|
| 271 |
-
volume_changes = []
|
| 272 |
-
for i in range(1, len(volumes_asc)):
|
| 273 |
-
prev_v = volumes_asc[i - 1]
|
| 274 |
-
change = (
|
| 275 |
-
((volumes_asc[i] - prev_v) / prev_v) * 100
|
| 276 |
-
if prev_v != 0 else 0
|
| 277 |
-
)
|
| 278 |
-
volume_changes.append(round(change, 2))
|
| 279 |
-
while len(volume_changes) < 5:
|
| 280 |
-
volume_changes.insert(0, 0.0)
|
| 281 |
-
res['VOLUME_CHANGES'] = volume_changes[-5:]
|
| 282 |
-
|
| 283 |
-
results[symbol] = res
|
| 284 |
-
except Exception as e:
|
| 285 |
-
logging.error(f"Failed to process calculations for {symbol}: {e}")
|
| 286 |
-
symbol_errors.append((symbol, str(e)))
|
| 287 |
-
|
| 288 |
-
# AUDIT FIX P-4: single vectorised join instead of per-symbol .loc
|
| 289 |
-
# scans. (Note: SYMBOL is categorical, so plain-string iteration is
|
| 290 |
-
# used — Series.map on a categorical would try to build categories
|
| 291 |
-
# from the mapped values, which fails for list-valued columns.)
|
| 292 |
-
row_symbols = date_data['SYMBOL'].astype(str).tolist()
|
| 293 |
-
for col, default in [
|
| 294 |
-
('WEEK_CHANGE', 0.0), ('MONTH_CHANGE', 0.0),
|
| 295 |
-
('THREE_MONTH_CHANGE', 0.0), ('TWO_YEAR_CHANGE', 0.0),
|
| 296 |
-
('AVG_VOLUME', 0.0),
|
| 297 |
-
]:
|
| 298 |
-
date_data[col] = pd.Series(
|
| 299 |
-
[results.get(s, {}).get(col, default) for s in row_symbols],
|
| 300 |
-
index=date_data.index, dtype=float,
|
| 301 |
-
)
|
| 302 |
-
for col in ('PRICE_HISTORY', 'VOLUME_HISTORY', 'VOLUME_CHANGES'):
|
| 303 |
-
date_data[col] = pd.Series(
|
| 304 |
-
[results.get(s, {}).get(col) for s in row_symbols],
|
| 305 |
-
index=date_data.index, dtype=object,
|
| 306 |
-
)
|
| 307 |
-
|
| 308 |
-
if symbol_errors:
|
| 309 |
-
logging.warning(
|
| 310 |
-
f"Completed date data load for {date} with "
|
| 311 |
-
f"{len(symbol_errors)} symbol failures. "
|
| 312 |
-
f"Failures: {symbol_errors[:10]}..."
|
| 313 |
-
)
|
| 314 |
-
logging.info(f"Successful to get data for {date}")
|
| 315 |
-
return date_data
|
| 316 |
-
|
| 317 |
-
# ------------------------------------------------------------------
|
| 318 |
-
# Volume analytics
|
| 319 |
-
# ------------------------------------------------------------------
|
| 320 |
-
|
| 321 |
-
@staticmethod
|
| 322 |
-
def _calculate_average_volume_change(symbol_data: pd.DataFrame,
|
| 323 |
-
days_back: int) -> float:
|
| 324 |
-
"""
|
| 325 |
-
Average daily volume % change for a symbol over ``days_back`` days.
|
| 326 |
-
``symbol_data`` must be sorted ascending by TRAN_DATE.
|
| 327 |
-
"""
|
| 328 |
-
if symbol_data.empty or len(symbol_data) < 2:
|
| 329 |
-
return 0.0
|
| 330 |
-
end_date = symbol_data['TRAN_DATE'].iloc[-1]
|
| 331 |
-
start_date = end_date - timedelta(days=days_back)
|
| 332 |
-
period = symbol_data[
|
| 333 |
-
(symbol_data['TRAN_DATE'] <= end_date) &
|
| 334 |
-
(symbol_data['TRAN_DATE'] > start_date - timedelta(days=1))
|
| 335 |
-
]
|
| 336 |
-
if len(period) < 2:
|
| 337 |
-
return 0.0
|
| 338 |
-
vols = period['NET_TRDQTY'].to_numpy(dtype=float)
|
| 339 |
-
prev, curr = vols[:-1], vols[1:]
|
| 340 |
-
with np.errstate(divide='ignore', invalid='ignore'):
|
| 341 |
-
changes = np.where(prev != 0, (curr - prev) / prev * 100, 0.0)
|
| 342 |
-
return float(changes.mean()) if changes.size else 0.0
|
| 343 |
-
|
| 344 |
-
@elapsed_time_logger("Fetching top volume movers...")
|
| 345 |
-
def get_top_volume_movers(self, duration: str, limit: int = ANALYSIS_LIMIT,
|
| 346 |
-
volume_cutoff: float = 0) -> pd.DataFrame:
|
| 347 |
-
"""
|
| 348 |
-
Rank stocks by average volume % change for the given duration
|
| 349 |
-
('1D', '1W', '1M', '3M'). ETFs excluded. Returns DataFrame with
|
| 350 |
-
SYMBOL, VOLUME_CHANGE_PCT, SECURITY, AVG_VOLUME.
|
| 351 |
-
"""
|
| 352 |
-
days_map = {"1D": 1, "1W": 7, "1M": 30, "3M": 90}
|
| 353 |
-
days_back = days_map.get(duration)
|
| 354 |
-
if days_back is None:
|
| 355 |
-
logging.error(f"Invalid duration for volume analysis: {duration}")
|
| 356 |
-
return pd.DataFrame()
|
| 357 |
-
|
| 358 |
-
with self._lock:
|
| 359 |
-
df = self.df
|
| 360 |
-
latest_date = df['TRAN_DATE'].max()
|
| 361 |
-
try:
|
| 362 |
-
all_latest = self.get_data_for_date(latest_date)
|
| 363 |
-
except Exception as e:
|
| 364 |
-
logging.error(
|
| 365 |
-
f"Error getting data for latest date in get_top_volume_movers: {e}"
|
| 366 |
-
)
|
| 367 |
-
raise RuntimeError(
|
| 368 |
-
"Could not retrieve necessary data for volume analysis."
|
| 369 |
-
) from e
|
| 370 |
-
if all_latest.empty:
|
| 371 |
-
logging.warning(
|
| 372 |
-
f"No data available for {latest_date} to perform volume analysis."
|
| 373 |
-
)
|
| 374 |
-
return pd.DataFrame()
|
| 375 |
-
|
| 376 |
-
grouped = df.groupby('SYMBOL', observed=True)
|
| 377 |
-
top_movers = []
|
| 378 |
-
for _, row in all_latest.iterrows():
|
| 379 |
-
symbol = row['SYMBOL']
|
| 380 |
-
if symbol in self.etf_symbols:
|
| 381 |
-
continue
|
| 382 |
-
if "ETF" in str(row['SECURITY']).upper():
|
| 383 |
-
continue
|
| 384 |
-
avg_volume = row.get('AVG_VOLUME', 0) or 0
|
| 385 |
-
if avg_volume < volume_cutoff:
|
| 386 |
-
continue
|
| 387 |
-
if duration == "1D":
|
| 388 |
-
vc = row.get('VOLUME_CHANGES') or []
|
| 389 |
-
volume_pct_change = vc[4] if len(vc) >= 5 else 0.0
|
| 390 |
-
else:
|
| 391 |
-
try:
|
| 392 |
-
hist = grouped.get_group(symbol)
|
| 393 |
-
except KeyError:
|
| 394 |
-
hist = pd.DataFrame()
|
| 395 |
-
volume_pct_change = self._calculate_average_volume_change(
|
| 396 |
-
hist, days_back
|
| 397 |
-
)
|
| 398 |
-
top_movers.append({
|
| 399 |
-
"SYMBOL": symbol,
|
| 400 |
-
"VOLUME_CHANGE_PCT": volume_pct_change,
|
| 401 |
-
"SECURITY": row['SECURITY'],
|
| 402 |
-
"AVG_VOLUME": avg_volume,
|
| 403 |
-
})
|
| 404 |
-
movers_df = pd.DataFrame(top_movers)
|
| 405 |
-
if movers_df.empty:
|
| 406 |
-
return pd.DataFrame()
|
| 407 |
-
return movers_df.sort_values(
|
| 408 |
-
by="VOLUME_CHANGE_PCT", ascending=False
|
| 409 |
-
).head(limit)
|
| 410 |
-
|
| 411 |
-
# ------------------------------------------------------------------
|
| 412 |
-
# Moneycontrol scores
|
| 413 |
-
# ------------------------------------------------------------------
|
| 414 |
-
|
| 415 |
-
@elapsed_time_logger("Loading and sorting Moneycontrol data by score...")
|
| 416 |
-
def get_top_mc_score_movers(self, limit: int = ANALYSIS_LIMIT) -> pd.DataFrame:
|
| 417 |
-
"""
|
| 418 |
-
Read nse_dashboard_mclink.json and return the top ``limit`` symbols
|
| 419 |
-
ranked by MC_SCORE descending.
|
| 420 |
-
"""
|
| 421 |
-
try:
|
| 422 |
-
if not self.paths.mc_link_file.exists():
|
| 423 |
-
logging.warning(
|
| 424 |
-
f"Moneycontrol link file not found: {self.paths.mc_link_file}."
|
| 425 |
-
)
|
| 426 |
-
return pd.DataFrame()
|
| 427 |
-
with open(self.paths.mc_link_file, 'r') as f:
|
| 428 |
-
mc_data = json.load(f)
|
| 429 |
-
processed = [
|
| 430 |
-
{"SYMBOL": symbol, "MC_SCORE": d['MC_SCORE'],
|
| 431 |
-
"SECURITY": d.get('MC_COMPANY_NAME', '')}
|
| 432 |
-
for symbol, d in mc_data.items() if 'MC_SCORE' in d
|
| 433 |
-
]
|
| 434 |
-
if not processed:
|
| 435 |
-
logging.warning("No valid MC_SCORE data found.")
|
| 436 |
-
return pd.DataFrame()
|
| 437 |
-
mc_df = pd.DataFrame(processed)
|
| 438 |
-
return mc_df.sort_values(by="MC_SCORE", ascending=False).head(limit)
|
| 439 |
-
except (json.JSONDecodeError, FileNotFoundError) as e:
|
| 440 |
-
logging.error(f"Error reading Moneycontrol data: {e}")
|
| 441 |
-
raise RuntimeError(f"Could not read Moneycontrol data: {e}") from e
|
| 442 |
-
except Exception as e:
|
| 443 |
-
logging.error(f"Unexpected error getting top MC score movers: {e}")
|
| 444 |
-
raise RuntimeError(f"Failed to analyze top MC scores: {e}") from e
|
| 445 |
-
|
| 446 |
-
# ------------------------------------------------------------------
|
| 447 |
-
# Moneycontrol link store (shared by all stock cards; AUDIT FIX P-7:
|
| 448 |
-
# v4.5 re-read the JSON from disk once per card per refresh)
|
| 449 |
-
# ------------------------------------------------------------------
|
| 450 |
-
|
| 451 |
-
def load_mc_link_data(self) -> dict:
|
| 452 |
-
"""Load nse_dashboard_mclink.json; create an empty file if missing."""
|
| 453 |
-
try:
|
| 454 |
-
p = Path(self.paths.mc_link_file)
|
| 455 |
-
if p.exists():
|
| 456 |
-
with open(p, 'r') as f:
|
| 457 |
-
return json.load(f)
|
| 458 |
-
with open(p, 'w') as f:
|
| 459 |
-
json.dump({}, f)
|
| 460 |
-
return {}
|
| 461 |
-
except Exception as e:
|
| 462 |
-
logging.error(
|
| 463 |
-
f"Error loading Moneycontrol links from {self.paths.mc_link_file}: {e}"
|
| 464 |
-
)
|
| 465 |
-
return {}
|
| 466 |
-
|
| 467 |
-
def save_mc_link_data(self, data: dict) -> None:
|
| 468 |
-
"""Write the updated Moneycontrol links dict back to JSON."""
|
| 469 |
-
try:
|
| 470 |
-
with open(self.paths.mc_link_file, 'w') as f:
|
| 471 |
-
json.dump(data, f, indent=4)
|
| 472 |
-
print(f"[ACTIVITY] Moneycontrol links config file updated: {self.paths.mc_link_file.name}", flush=True)
|
| 473 |
-
except Exception as e:
|
| 474 |
-
logging.error(
|
| 475 |
-
f"Error saving Moneycontrol links to {self.paths.mc_link_file}: {e}"
|
| 476 |
-
)
|
| 477 |
-
|
| 478 |
-
def set_mc_link(self, symbol: str, security: str, link: str) -> dict:
|
| 479 |
-
"""
|
| 480 |
-
Register/replace the Moneycontrol URL for a symbol (parity with the
|
| 481 |
-
v4.5 URL-prompt flow, including MC_COMPANY_CODE extraction).
|
| 482 |
-
"""
|
| 483 |
-
input_link = (link or "").strip()
|
| 484 |
-
if not input_link:
|
| 485 |
-
raise ValueError("Please enter the Moneycontrol URL.")
|
| 486 |
-
url_parts = input_link.strip('/').split('/')
|
| 487 |
-
input_mc_code = next((p for p in reversed(url_parts) if p), None)
|
| 488 |
-
if not input_mc_code:
|
| 489 |
-
raise ValueError("Could not extract MC Company Code from the URL.")
|
| 490 |
-
mc_links = self.load_mc_link_data()
|
| 491 |
-
if symbol in mc_links:
|
| 492 |
-
mc_links[symbol]["MC_LINK"] = input_link
|
| 493 |
-
mc_links[symbol]["MC_COMPANY_CODE"] = input_mc_code
|
| 494 |
-
mc_links[symbol]["MC_COMPANY_NAME"] = security
|
| 495 |
-
else:
|
| 496 |
-
mc_links[symbol] = {
|
| 497 |
-
"MC_ATH": 0.0, "MC_ATL": 0.0,
|
| 498 |
-
"MC_COMPANY_CODE": input_mc_code,
|
| 499 |
-
"MC_COMPANY_NAME": security,
|
| 500 |
-
"MC_FV": 0.0, "MC_LINK": input_link,
|
| 501 |
-
"MC_MCAP": 0.0, "MC_SCORE": 0.0, "MC_STATUS": False,
|
| 502 |
-
}
|
| 503 |
-
self.save_mc_link_data(mc_links)
|
| 504 |
-
return mc_links[symbol]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/token_state.json
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"prompt_tokens": 186398,
|
| 3 |
-
"completion_tokens": 72204,
|
| 4 |
-
"total_tokens": 258602,
|
| 5 |
-
"per_model": {
|
| 6 |
-
"nvidia/llama-3.3-nemotron-super-49b-v1": {
|
| 7 |
-
"prompt_tokens": 5935,
|
| 8 |
-
"completion_tokens": 1999,
|
| 9 |
-
"total_tokens": 7934
|
| 10 |
-
},
|
| 11 |
-
"nvidia/nemotron-3-ultra-550b-a55b": {
|
| 12 |
-
"prompt_tokens": 8822,
|
| 13 |
-
"completion_tokens": 8192,
|
| 14 |
-
"total_tokens": 17014
|
| 15 |
-
},
|
| 16 |
-
"nvidia/llama-3.3-nemotron-super-49b-v1.5": {
|
| 17 |
-
"prompt_tokens": 15581,
|
| 18 |
-
"completion_tokens": 11471,
|
| 19 |
-
"total_tokens": 27052
|
| 20 |
-
},
|
| 21 |
-
"meta/llama-3.1-70b-instruct": {
|
| 22 |
-
"prompt_tokens": 17996,
|
| 23 |
-
"completion_tokens": 5482,
|
| 24 |
-
"total_tokens": 23478
|
| 25 |
-
},
|
| 26 |
-
"thinkingmachines/inkling": {
|
| 27 |
-
"prompt_tokens": 3426,
|
| 28 |
-
"completion_tokens": 4096,
|
| 29 |
-
"total_tokens": 7522
|
| 30 |
-
},
|
| 31 |
-
"google/diffusiongemma-26b-a4b-it": {
|
| 32 |
-
"prompt_tokens": 134638,
|
| 33 |
-
"completion_tokens": 40964,
|
| 34 |
-
"total_tokens": 175602
|
| 35 |
-
}
|
| 36 |
-
}
|
| 37 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/user_auth.py
DELETED
|
@@ -1,835 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.user_auth
|
| 3 |
-
=======================
|
| 4 |
-
User authentication and session management for nsedash — ported from
|
| 5 |
-
the mlbench ``security/app_security.py`` module and adapted for the
|
| 6 |
-
nsedash Flask API.
|
| 7 |
-
|
| 8 |
-
Authentication flow
|
| 9 |
-
--------------------
|
| 10 |
-
1. ``POST /api/auth/login`` — userid + password → bcrypt verify → TOTP required
|
| 11 |
-
2. ``POST /api/auth/totp`` — userid + TOTP code → issues a session token
|
| 12 |
-
3. All subsequent API calls send ``X-Session-Token`` header.
|
| 13 |
-
4. ``SessionManager.validate_session()`` resolves the token → userid.
|
| 14 |
-
5. ``_svc(userid)`` creates / retrieves the per-user ``DashboardService``.
|
| 15 |
-
|
| 16 |
-
Seed users (from mlbench users.db)
|
| 17 |
-
------------------------------------
|
| 18 |
-
The file ``security/users.db`` is created on first run using the same
|
| 19 |
-
schema as mlbench. The initial user set is:
|
| 20 |
-
admin, sam, mainak, tunir, rama
|
| 21 |
-
|
| 22 |
-
Schema of users.db
|
| 23 |
-
--------------------
|
| 24 |
-
{
|
| 25 |
-
"users": {
|
| 26 |
-
"<userid>": {
|
| 27 |
-
"userid": str,
|
| 28 |
-
"password_hash": str | null,
|
| 29 |
-
"email": str | null,
|
| 30 |
-
"totp_secret": str | null,
|
| 31 |
-
"totp_secret_encrypted": str | null,
|
| 32 |
-
"is_admin": bool,
|
| 33 |
-
"created_at": ISO8601,
|
| 34 |
-
"last_login": ISO8601 | null,
|
| 35 |
-
"login_count": int,
|
| 36 |
-
"active": bool,
|
| 37 |
-
"login_status": "online" | "offline"
|
| 38 |
-
}
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
"""
|
| 42 |
-
|
| 43 |
-
from __future__ import annotations
|
| 44 |
-
|
| 45 |
-
import base64
|
| 46 |
-
import json
|
| 47 |
-
import logging
|
| 48 |
-
import os
|
| 49 |
-
import secrets
|
| 50 |
-
from datetime import datetime, timedelta
|
| 51 |
-
from pathlib import Path
|
| 52 |
-
from typing import Any, Dict, Optional, Tuple
|
| 53 |
-
|
| 54 |
-
log = logging.getLogger(__name__)
|
| 55 |
-
|
| 56 |
-
# ---------------------------------------------------------------------------
|
| 57 |
-
# File paths (relative to the app base_dir — set via UserAuth.init())
|
| 58 |
-
# ---------------------------------------------------------------------------
|
| 59 |
-
|
| 60 |
-
_SECURITY_DIR: Path | None = None
|
| 61 |
-
_USERS_DB_FILE: Path | None = None
|
| 62 |
-
_ENCRYPTION_KEY_FILE: Path | None = None
|
| 63 |
-
_SESSION_TOKENS_FILE: Path | None = None
|
| 64 |
-
_HF_SYNC = None
|
| 65 |
-
|
| 66 |
-
import re
|
| 67 |
-
|
| 68 |
-
SESSION_EXPIRATION_HOURS = 24
|
| 69 |
-
TOTP_WINDOW = 1 # ±1 30-second window
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def validate_password_policy(password: str) -> Tuple[bool, str]:
|
| 73 |
-
"""
|
| 74 |
-
Validates password against policy:
|
| 75 |
-
- Minimum length: 8 characters
|
| 76 |
-
- At least 1 uppercase letter (A-Z)
|
| 77 |
-
- At least 1 lowercase letter (a-z)
|
| 78 |
-
- At least 1 digit (0-9)
|
| 79 |
-
- At least 1 special character (!@#$%^&*()_+-=[]{}|;:,.<>?)
|
| 80 |
-
"""
|
| 81 |
-
if not password or len(password) < 8:
|
| 82 |
-
return False, "Password must be at least 8 characters long."
|
| 83 |
-
if not re.search(r"[A-Z]", password):
|
| 84 |
-
return False, "Password must contain at least 1 uppercase letter (A-Z)."
|
| 85 |
-
if not re.search(r"[a-z]", password):
|
| 86 |
-
return False, "Password must contain at least 1 lowercase letter (a-z)."
|
| 87 |
-
if not re.search(r"[0-9]", password):
|
| 88 |
-
return False, "Password must contain at least 1 number (0-9)."
|
| 89 |
-
if not re.search(r"[^A-Za-z0-9]", password):
|
| 90 |
-
return False, "Password must contain at least 1 special character (e.g. !@#$%^&*)."
|
| 91 |
-
return True, "OK"
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def init(base_dir: Path | str, hf_sync=None) -> None:
|
| 95 |
-
"""
|
| 96 |
-
Initialise paths. Must be called once from ``create_app()`` before
|
| 97 |
-
any auth function is used.
|
| 98 |
-
"""
|
| 99 |
-
global _SECURITY_DIR, _USERS_DB_FILE, _ENCRYPTION_KEY_FILE, _SESSION_TOKENS_FILE, _HF_SYNC
|
| 100 |
-
base_dir = Path(base_dir)
|
| 101 |
-
_SECURITY_DIR = base_dir / "processed" / "security"
|
| 102 |
-
_SECURITY_DIR.mkdir(parents=True, exist_ok=True)
|
| 103 |
-
_USERS_DB_FILE = _SECURITY_DIR / "users.db"
|
| 104 |
-
_ENCRYPTION_KEY_FILE = _SECURITY_DIR / "encryption_key.key"
|
| 105 |
-
if not _ENCRYPTION_KEY_FILE.exists() and (_SECURITY_DIR / ".encryption_key").exists():
|
| 106 |
-
_ENCRYPTION_KEY_FILE = _SECURITY_DIR / ".encryption_key"
|
| 107 |
-
_SESSION_TOKENS_FILE = _SECURITY_DIR / ".session_tokens.json"
|
| 108 |
-
_HF_SYNC = hf_sync
|
| 109 |
-
|
| 110 |
-
# Initialise encrypted SQLite database (userdata.db)
|
| 111 |
-
from .userdata_db import init as init_userdata_db
|
| 112 |
-
init_userdata_db(base_dir, hf_sync=hf_sync)
|
| 113 |
-
|
| 114 |
-
log.info("[Auth] Initialised security dir: %s (hf_sync linked: %s)", _SECURITY_DIR, hf_sync is not None)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
def _check_init():
|
| 118 |
-
if _USERS_DB_FILE is None:
|
| 119 |
-
raise RuntimeError("user_auth.init(base_dir) must be called first")
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
# ---------------------------------------------------------------------------
|
| 123 |
-
# Encryption (Fernet — same key format as mlbench)
|
| 124 |
-
# ---------------------------------------------------------------------------
|
| 125 |
-
|
| 126 |
-
class EncryptionManager:
|
| 127 |
-
"""Handles encryption/decryption of TOTP secrets."""
|
| 128 |
-
|
| 129 |
-
@staticmethod
|
| 130 |
-
def _get_or_create_key() -> bytes:
|
| 131 |
-
_check_init()
|
| 132 |
-
from cryptography.fernet import Fernet
|
| 133 |
-
if _ENCRYPTION_KEY_FILE.exists():
|
| 134 |
-
return _ENCRYPTION_KEY_FILE.read_bytes()
|
| 135 |
-
key = Fernet.generate_key()
|
| 136 |
-
_ENCRYPTION_KEY_FILE.write_bytes(key)
|
| 137 |
-
log.info("[Auth] Encryption key created.")
|
| 138 |
-
if _HF_SYNC:
|
| 139 |
-
_HF_SYNC.push_file_async(_ENCRYPTION_KEY_FILE)
|
| 140 |
-
return key
|
| 141 |
-
|
| 142 |
-
@staticmethod
|
| 143 |
-
def encrypt(plaintext: str) -> str:
|
| 144 |
-
try:
|
| 145 |
-
from cryptography.fernet import Fernet
|
| 146 |
-
key = EncryptionManager._get_or_create_key()
|
| 147 |
-
ciphertext = Fernet(key).encrypt(plaintext.encode())
|
| 148 |
-
return base64.b64encode(ciphertext).decode()
|
| 149 |
-
except Exception as exc:
|
| 150 |
-
log.error("[Auth] Encryption error: %s", exc)
|
| 151 |
-
return plaintext
|
| 152 |
-
|
| 153 |
-
@staticmethod
|
| 154 |
-
def decrypt(ciphertext: str) -> str | None:
|
| 155 |
-
try:
|
| 156 |
-
from cryptography.fernet import Fernet
|
| 157 |
-
key = EncryptionManager._get_or_create_key()
|
| 158 |
-
raw = base64.b64decode(ciphertext.encode())
|
| 159 |
-
return Fernet(key).decrypt(raw).decode()
|
| 160 |
-
except Exception as exc:
|
| 161 |
-
log.error("[Auth] Decryption error: %s", exc)
|
| 162 |
-
return None
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# ---------------------------------------------------------------------------
|
| 166 |
-
# User Database
|
| 167 |
-
# ---------------------------------------------------------------------------
|
| 168 |
-
|
| 169 |
-
# Seed users ported from mlbench users.db.
|
| 170 |
-
# Passwords are null (set on first login via TOTP setup flow).
|
| 171 |
-
# Only the admin entry has the real bcrypt hash from mlbench.
|
| 172 |
-
_SEED_USERS: Dict[str, Any] = {
|
| 173 |
-
"admin": {
|
| 174 |
-
"userid": "admin",
|
| 175 |
-
"password_hash": "pbkdf2:sha256:1000000$3QTFDU2NQmIiNJCs$8e2fa3fd5daae1c5270426e2c2080c600a11fbeabee071f115ec5f584098571b",
|
| 176 |
-
"email": None,
|
| 177 |
-
"totp_secret": None,
|
| 178 |
-
"totp_secret_encrypted": None,
|
| 179 |
-
"is_admin": True,
|
| 180 |
-
"created_at": "2026-02-28T16:05:18.324809",
|
| 181 |
-
"last_login": None,
|
| 182 |
-
"login_count": 0,
|
| 183 |
-
"active": True,
|
| 184 |
-
"login_status": "offline",
|
| 185 |
-
},
|
| 186 |
-
"sam": {
|
| 187 |
-
"userid": "sam",
|
| 188 |
-
"password_hash": "pbkdf2:sha256:1000000$zAgOwq9jWdoLseRy$bec16031d940efcdb7b83b5d34735b3a11bcf4ce613ff7c5e0252c01d98cbdd5",
|
| 189 |
-
"email": None,
|
| 190 |
-
"totp_secret": None,
|
| 191 |
-
"totp_secret_encrypted": None,
|
| 192 |
-
"is_admin": False,
|
| 193 |
-
"created_at": "2026-03-26T00:31:49.856999",
|
| 194 |
-
"last_login": None,
|
| 195 |
-
"login_count": 0,
|
| 196 |
-
"active": True,
|
| 197 |
-
"login_status": "offline",
|
| 198 |
-
},
|
| 199 |
-
"mainak": {
|
| 200 |
-
"userid": "mainak",
|
| 201 |
-
"password_hash": None,
|
| 202 |
-
"email": None,
|
| 203 |
-
"totp_secret": None,
|
| 204 |
-
"totp_secret_encrypted": None,
|
| 205 |
-
"is_admin": False,
|
| 206 |
-
"created_at": "2026-02-28T20:58:29.175636",
|
| 207 |
-
"last_login": None,
|
| 208 |
-
"login_count": 0,
|
| 209 |
-
"active": True,
|
| 210 |
-
"login_status": "offline",
|
| 211 |
-
},
|
| 212 |
-
"tunir": {
|
| 213 |
-
"userid": "tunir",
|
| 214 |
-
"password_hash": None,
|
| 215 |
-
"email": None,
|
| 216 |
-
"totp_secret": None,
|
| 217 |
-
"totp_secret_encrypted": None,
|
| 218 |
-
"is_admin": False,
|
| 219 |
-
"created_at": "2026-02-28T20:58:42.323454",
|
| 220 |
-
"last_login": None,
|
| 221 |
-
"login_count": 0,
|
| 222 |
-
"active": True,
|
| 223 |
-
"login_status": "offline",
|
| 224 |
-
},
|
| 225 |
-
"rama": {
|
| 226 |
-
"userid": "rama",
|
| 227 |
-
"password_hash": None,
|
| 228 |
-
"email": None,
|
| 229 |
-
"totp_secret": None,
|
| 230 |
-
"totp_secret_encrypted": None,
|
| 231 |
-
"is_admin": False,
|
| 232 |
-
"created_at": "2026-02-28T23:18:58.881545",
|
| 233 |
-
"last_login": None,
|
| 234 |
-
"login_count": 0,
|
| 235 |
-
"active": True,
|
| 236 |
-
"login_status": "offline",
|
| 237 |
-
},
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
class UserDatabase:
|
| 242 |
-
"""JSON-backed user database — same schema as mlbench users.db."""
|
| 243 |
-
|
| 244 |
-
@staticmethod
|
| 245 |
-
def load() -> Dict[str, Any]:
|
| 246 |
-
_check_init()
|
| 247 |
-
if not _USERS_DB_FILE.exists():
|
| 248 |
-
return UserDatabase._create_defaults()
|
| 249 |
-
try:
|
| 250 |
-
return json.loads(_USERS_DB_FILE.read_text())
|
| 251 |
-
except Exception as exc:
|
| 252 |
-
log.error("[Auth] Failed to load users.db: %s", exc)
|
| 253 |
-
return UserDatabase._create_defaults()
|
| 254 |
-
|
| 255 |
-
@staticmethod
|
| 256 |
-
def _create_defaults() -> Dict[str, Any]:
|
| 257 |
-
data = {"users": _SEED_USERS.copy()}
|
| 258 |
-
UserDatabase.save(data)
|
| 259 |
-
log.warning("[Auth] Created default users.db from seed (admin, sam, mainak, tunir, rama)")
|
| 260 |
-
return data
|
| 261 |
-
|
| 262 |
-
@staticmethod
|
| 263 |
-
def save(data: Dict[str, Any]) -> bool:
|
| 264 |
-
_check_init()
|
| 265 |
-
try:
|
| 266 |
-
_USERS_DB_FILE.write_text(json.dumps(data, indent=2))
|
| 267 |
-
if _HF_SYNC:
|
| 268 |
-
_HF_SYNC.push_file_async(_USERS_DB_FILE)
|
| 269 |
-
return True
|
| 270 |
-
except Exception as exc:
|
| 271 |
-
log.error("[Auth] Failed to save users.db: %s", exc)
|
| 272 |
-
return False
|
| 273 |
-
|
| 274 |
-
@staticmethod
|
| 275 |
-
def get_user(userid: str) -> Optional[Dict[str, Any]]:
|
| 276 |
-
u = UserDatabase.load().get("users", {}).get(userid)
|
| 277 |
-
if u and userid == "admin":
|
| 278 |
-
u["is_admin"] = True
|
| 279 |
-
return u
|
| 280 |
-
|
| 281 |
-
@staticmethod
|
| 282 |
-
def update_user(userid: str, updates: Dict[str, Any]) -> bool:
|
| 283 |
-
data = UserDatabase.load()
|
| 284 |
-
if userid not in data.get("users", {}):
|
| 285 |
-
return False
|
| 286 |
-
data["users"][userid].update(updates)
|
| 287 |
-
if userid == "admin":
|
| 288 |
-
data["users"][userid]["is_admin"] = True
|
| 289 |
-
return UserDatabase.save(data)
|
| 290 |
-
|
| 291 |
-
@staticmethod
|
| 292 |
-
def create_user(userid: str, is_admin: bool = False) -> bool:
|
| 293 |
-
if UserDatabase.get_user(userid):
|
| 294 |
-
return False
|
| 295 |
-
data = UserDatabase.load()
|
| 296 |
-
data["users"][userid] = {
|
| 297 |
-
"userid": userid,
|
| 298 |
-
"password_hash": None,
|
| 299 |
-
"email": None,
|
| 300 |
-
"totp_secret": None,
|
| 301 |
-
"totp_secret_encrypted": None,
|
| 302 |
-
"is_admin": True if userid == "admin" else is_admin,
|
| 303 |
-
"created_at": datetime.now().isoformat(),
|
| 304 |
-
"last_login": None,
|
| 305 |
-
"login_count": 0,
|
| 306 |
-
"active": True,
|
| 307 |
-
"login_status": "offline",
|
| 308 |
-
}
|
| 309 |
-
return UserDatabase.save(data)
|
| 310 |
-
|
| 311 |
-
@staticmethod
|
| 312 |
-
def delete_user(userid: str) -> bool:
|
| 313 |
-
if userid == "admin":
|
| 314 |
-
return False
|
| 315 |
-
data = UserDatabase.load()
|
| 316 |
-
if userid in data.get("users", {}):
|
| 317 |
-
del data["users"][userid]
|
| 318 |
-
return UserDatabase.save(data)
|
| 319 |
-
return False
|
| 320 |
-
|
| 321 |
-
@staticmethod
|
| 322 |
-
def reset_totp(userid: str) -> bool:
|
| 323 |
-
data = UserDatabase.load()
|
| 324 |
-
if userid in data.get("users", {}):
|
| 325 |
-
data["users"][userid]["password_hash"] = None
|
| 326 |
-
data["users"][userid]["totp_secret"] = None
|
| 327 |
-
data["users"][userid]["totp_secret_encrypted"] = None
|
| 328 |
-
if userid == "admin":
|
| 329 |
-
data["users"][userid]["is_admin"] = True
|
| 330 |
-
return UserDatabase.save(data)
|
| 331 |
-
return False
|
| 332 |
-
|
| 333 |
-
@staticmethod
|
| 334 |
-
def get_all_users() -> Dict[str, Any]:
|
| 335 |
-
users = UserDatabase.load().get("users", {})
|
| 336 |
-
return {
|
| 337 |
-
uid: {
|
| 338 |
-
"userid": uid,
|
| 339 |
-
"is_admin": True if uid == "admin" else u.get("is_admin", False),
|
| 340 |
-
"active": u.get("active"),
|
| 341 |
-
"last_login": u.get("last_login"),
|
| 342 |
-
"login_count": u.get("login_count", 0),
|
| 343 |
-
"login_status": u.get("login_status", "offline"),
|
| 344 |
-
}
|
| 345 |
-
for uid, u in users.items()
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
# ---------------------------------------------------------------------------
|
| 350 |
-
# Authentication
|
| 351 |
-
# ---------------------------------------------------------------------------
|
| 352 |
-
|
| 353 |
-
class AuthenticationService:
|
| 354 |
-
"""Userid + bcrypt password + TOTP — same flow as mlbench."""
|
| 355 |
-
|
| 356 |
-
@staticmethod
|
| 357 |
-
def login(userid: str, password: str) -> Tuple[bool, str, bool]:
|
| 358 |
-
"""
|
| 359 |
-
Verify password.
|
| 360 |
-
|
| 361 |
-
Returns
|
| 362 |
-
-------
|
| 363 |
-
(success, message, totp_required)
|
| 364 |
-
"""
|
| 365 |
-
user = UserDatabase.get_user(userid)
|
| 366 |
-
if not user:
|
| 367 |
-
ActivityLogger.log_activity(userid, "login", "failed", "User not found")
|
| 368 |
-
return False, "User not found.", False
|
| 369 |
-
if not user.get("active", True):
|
| 370 |
-
ActivityLogger.log_activity(userid, "login", "failed", "Account inactive")
|
| 371 |
-
return False, "Account inactive.", False
|
| 372 |
-
|
| 373 |
-
from werkzeug.security import generate_password_hash, check_password_hash
|
| 374 |
-
|
| 375 |
-
if user.get("password_hash") is None:
|
| 376 |
-
# First login — set password and generate TOTP secret
|
| 377 |
-
pw_hash = generate_password_hash(password, method="pbkdf2:sha256")
|
| 378 |
-
import pyotp
|
| 379 |
-
totp_secret = pyotp.random_base32()
|
| 380 |
-
enc = EncryptionManager.encrypt(totp_secret)
|
| 381 |
-
UserDatabase.update_user(userid, {
|
| 382 |
-
"password_hash": pw_hash,
|
| 383 |
-
"totp_secret": totp_secret,
|
| 384 |
-
"totp_secret_encrypted": enc,
|
| 385 |
-
})
|
| 386 |
-
log.info("[Auth] First login for '%s' — TOTP provisioned.", userid)
|
| 387 |
-
ActivityLogger.log_activity(userid, "login", "first_totp_setup", "First TOTP setup triggered")
|
| 388 |
-
return True, "first_login", True # caller should show QR
|
| 389 |
-
|
| 390 |
-
if not check_password_hash(user["password_hash"], password):
|
| 391 |
-
ActivityLogger.log_activity(userid, "login", "failed", "Invalid password")
|
| 392 |
-
return False, "Invalid password.", False
|
| 393 |
-
|
| 394 |
-
# Validate password policy compliance
|
| 395 |
-
valid_pw, _ = validate_password_policy(password)
|
| 396 |
-
if not valid_pw or user.get("force_password_change"):
|
| 397 |
-
UserDatabase.update_user(userid, {"force_password_change": True})
|
| 398 |
-
|
| 399 |
-
# If TOTP not yet set up (e.g. admin copied over without secret)
|
| 400 |
-
if not user.get("totp_secret"):
|
| 401 |
-
try:
|
| 402 |
-
import pyotp
|
| 403 |
-
except ImportError:
|
| 404 |
-
ActivityLogger.log_activity(userid, "login", "error", "pyotp missing")
|
| 405 |
-
return False, "Server error: pyotp library is missing", False
|
| 406 |
-
|
| 407 |
-
totp_secret = pyotp.random_base32()
|
| 408 |
-
enc = EncryptionManager.encrypt(totp_secret)
|
| 409 |
-
if not UserDatabase.update_user(userid, {
|
| 410 |
-
"totp_secret": totp_secret,
|
| 411 |
-
"totp_secret_encrypted": enc,
|
| 412 |
-
}):
|
| 413 |
-
ActivityLogger.log_activity(userid, "login", "error", "failed_to_save_totp")
|
| 414 |
-
return False, "Server error: Failed to save TOTP data", False
|
| 415 |
-
|
| 416 |
-
ActivityLogger.log_activity(userid, "login", "totp_setup", "TOTP setup triggered")
|
| 417 |
-
return True, "totp_setup", True
|
| 418 |
-
|
| 419 |
-
ActivityLogger.log_activity(userid, "login", "password_verified", "Password verified successfully")
|
| 420 |
-
return True, "ok", True
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
def change_user_password(userid: str, current_password: str | None, new_password: str, confirm_password: str, is_admin_override: bool = False) -> Tuple[bool, str]:
|
| 424 |
-
"""
|
| 425 |
-
Change user password enforcing:
|
| 426 |
-
1. Reconfirmation entry (new_password == confirm_password)
|
| 427 |
-
2. Password policy (min 8 chars, 1 upper, 1 lower, 1 digit, 1 special char)
|
| 428 |
-
3. Current password verification (unless admin override)
|
| 429 |
-
"""
|
| 430 |
-
if new_password != confirm_password:
|
| 431 |
-
return False, "New password and confirm password do not match."
|
| 432 |
-
|
| 433 |
-
valid, msg = validate_password_policy(new_password)
|
| 434 |
-
if not valid:
|
| 435 |
-
return False, msg
|
| 436 |
-
|
| 437 |
-
user = UserDatabase.get_user(userid)
|
| 438 |
-
if not user:
|
| 439 |
-
return False, "User not found."
|
| 440 |
-
|
| 441 |
-
from werkzeug.security import generate_password_hash, check_password_hash
|
| 442 |
-
|
| 443 |
-
if not is_admin_override:
|
| 444 |
-
if not current_password or not user.get("password_hash"):
|
| 445 |
-
return False, "Current password is required."
|
| 446 |
-
if not check_password_hash(user["password_hash"], current_password):
|
| 447 |
-
return False, "Current password is incorrect."
|
| 448 |
-
|
| 449 |
-
new_hash = generate_password_hash(new_password, method="pbkdf2:sha256")
|
| 450 |
-
UserDatabase.update_user(userid, {
|
| 451 |
-
"password_hash": new_hash,
|
| 452 |
-
"force_password_change": False
|
| 453 |
-
})
|
| 454 |
-
ActivityLogger.log_activity(userid, "change_password", "success", "Password updated successfully")
|
| 455 |
-
return True, "Password changed successfully."
|
| 456 |
-
|
| 457 |
-
@staticmethod
|
| 458 |
-
def get_totp_qr(userid: str) -> str | None:
|
| 459 |
-
"""Return base64 PNG data-URI for TOTP QR code."""
|
| 460 |
-
user = UserDatabase.get_user(userid)
|
| 461 |
-
if not user:
|
| 462 |
-
return None
|
| 463 |
-
secret = user.get("totp_secret")
|
| 464 |
-
if not secret and user.get("totp_secret_encrypted"):
|
| 465 |
-
secret = EncryptionManager.decrypt(user["totp_secret_encrypted"])
|
| 466 |
-
if not secret:
|
| 467 |
-
return None
|
| 468 |
-
try:
|
| 469 |
-
import pyotp
|
| 470 |
-
import qrcode
|
| 471 |
-
except ImportError as exc:
|
| 472 |
-
log.error("[Auth] Missing dependencies for QR generation: %s", exc)
|
| 473 |
-
return None
|
| 474 |
-
|
| 475 |
-
try:
|
| 476 |
-
from io import BytesIO
|
| 477 |
-
uri = pyotp.TOTP(secret).provisioning_uri(
|
| 478 |
-
name=userid, issuer_name="NSE Dashboard"
|
| 479 |
-
)
|
| 480 |
-
qr = qrcode.make(uri)
|
| 481 |
-
buf = BytesIO()
|
| 482 |
-
qr.save(buf, format="PNG")
|
| 483 |
-
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 484 |
-
except Exception as exc:
|
| 485 |
-
log.error("[Auth] QR generation failed for '%s': %s", userid, exc)
|
| 486 |
-
return None
|
| 487 |
-
|
| 488 |
-
@staticmethod
|
| 489 |
-
def verify_totp(userid: str, code: str) -> Tuple[bool, str]:
|
| 490 |
-
"""Verify TOTP code and — on success — issue a session token."""
|
| 491 |
-
user = UserDatabase.get_user(userid)
|
| 492 |
-
if not user:
|
| 493 |
-
ActivityLogger.log_activity(userid, "totp_verification", "failed", "User not found")
|
| 494 |
-
return False, "User not found."
|
| 495 |
-
secret = user.get("totp_secret")
|
| 496 |
-
if not secret and user.get("totp_secret_encrypted"):
|
| 497 |
-
secret = EncryptionManager.decrypt(user["totp_secret_encrypted"])
|
| 498 |
-
if not secret:
|
| 499 |
-
ActivityLogger.log_activity(userid, "totp_verification", "failed", "TOTP not configured")
|
| 500 |
-
return False, "TOTP not configured."
|
| 501 |
-
try:
|
| 502 |
-
import pyotp
|
| 503 |
-
totp = pyotp.TOTP(secret)
|
| 504 |
-
if not totp.verify(code, valid_window=TOTP_WINDOW):
|
| 505 |
-
ActivityLogger.log_activity(userid, "totp_verification", "failed", "Invalid TOTP code")
|
| 506 |
-
return False, "Invalid TOTP code."
|
| 507 |
-
except Exception as exc:
|
| 508 |
-
ActivityLogger.log_activity(userid, "totp_verification", "error", f"TOTP error: {exc}")
|
| 509 |
-
return False, f"TOTP error: {exc}"
|
| 510 |
-
|
| 511 |
-
# Success — update login metadata
|
| 512 |
-
cnt = user.get("login_count", 0)
|
| 513 |
-
UserDatabase.update_user(userid, {
|
| 514 |
-
"last_login": datetime.now().isoformat(),
|
| 515 |
-
"login_count": cnt + 1,
|
| 516 |
-
"login_status": "online",
|
| 517 |
-
})
|
| 518 |
-
token = SessionManager.create_session(userid)
|
| 519 |
-
ActivityLogger.log_activity(userid, "login", "success", "TOTP verified, logged in")
|
| 520 |
-
return True, token
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
# ---------------------------------------------------------------------------
|
| 525 |
-
# Session Manager
|
| 526 |
-
# ---------------------------------------------------------------------------
|
| 527 |
-
|
| 528 |
-
class SessionManager:
|
| 529 |
-
"""File-backed session tokens — same approach as mlbench SessionManager."""
|
| 530 |
-
|
| 531 |
-
@staticmethod
|
| 532 |
-
def _load() -> Dict[str, Any]:
|
| 533 |
-
_check_init()
|
| 534 |
-
if not _SESSION_TOKENS_FILE or not _SESSION_TOKENS_FILE.exists():
|
| 535 |
-
return {}
|
| 536 |
-
try:
|
| 537 |
-
return json.loads(_SESSION_TOKENS_FILE.read_text())
|
| 538 |
-
except Exception:
|
| 539 |
-
return {}
|
| 540 |
-
|
| 541 |
-
@staticmethod
|
| 542 |
-
def _save(sessions: Dict[str, Any]) -> None:
|
| 543 |
-
_check_init()
|
| 544 |
-
try:
|
| 545 |
-
_SESSION_TOKENS_FILE.write_text(json.dumps(sessions, indent=2))
|
| 546 |
-
if _HF_SYNC:
|
| 547 |
-
_HF_SYNC.push_file_async(_SESSION_TOKENS_FILE)
|
| 548 |
-
except Exception as exc:
|
| 549 |
-
log.error("[Auth] Failed to save sessions: %s", exc)
|
| 550 |
-
|
| 551 |
-
@staticmethod
|
| 552 |
-
def create_session(userid: str) -> str:
|
| 553 |
-
token = secrets.token_urlsafe(32)
|
| 554 |
-
sessions = SessionManager._load()
|
| 555 |
-
sessions[token] = {
|
| 556 |
-
"userid": userid,
|
| 557 |
-
"created_at": datetime.now().isoformat(),
|
| 558 |
-
"last_activity": datetime.now().isoformat(),
|
| 559 |
-
}
|
| 560 |
-
SessionManager._save(sessions)
|
| 561 |
-
log.info("[Auth] Session created for '%s'", userid)
|
| 562 |
-
return token
|
| 563 |
-
|
| 564 |
-
@staticmethod
|
| 565 |
-
def validate_session(token: str) -> Tuple[bool, Optional[str]]:
|
| 566 |
-
"""Returns (valid, userid)."""
|
| 567 |
-
if not token:
|
| 568 |
-
return False, None
|
| 569 |
-
sessions = SessionManager._load()
|
| 570 |
-
entry = sessions.get(token)
|
| 571 |
-
if not entry:
|
| 572 |
-
return False, None
|
| 573 |
-
created = datetime.fromisoformat(entry["created_at"])
|
| 574 |
-
if (datetime.now() - created).total_seconds() > SESSION_EXPIRATION_HOURS * 3600:
|
| 575 |
-
del sessions[token]
|
| 576 |
-
SessionManager._save(sessions)
|
| 577 |
-
return False, None
|
| 578 |
-
# Touch last_activity
|
| 579 |
-
entry["last_activity"] = datetime.now().isoformat()
|
| 580 |
-
SessionManager._save(sessions)
|
| 581 |
-
return True, entry["userid"]
|
| 582 |
-
|
| 583 |
-
@staticmethod
|
| 584 |
-
def destroy_session(token: str) -> bool:
|
| 585 |
-
sessions = SessionManager._load()
|
| 586 |
-
if token in sessions:
|
| 587 |
-
userid = sessions[token].get("userid", "?")
|
| 588 |
-
del sessions[token]
|
| 589 |
-
SessionManager._save(sessions)
|
| 590 |
-
# Mark user offline
|
| 591 |
-
UserDatabase.update_user(userid, {"login_status": "offline"})
|
| 592 |
-
log.info("[Auth] Session destroyed for '%s'", userid)
|
| 593 |
-
ActivityLogger.log_activity(userid, "logout", "success", "Logged out successfully")
|
| 594 |
-
return True
|
| 595 |
-
return False
|
| 596 |
-
|
| 597 |
-
@staticmethod
|
| 598 |
-
def get_userid_sessions(userid: str) -> list[str]:
|
| 599 |
-
"""Return all active token strings for a given userid."""
|
| 600 |
-
sessions = SessionManager._load()
|
| 601 |
-
return [tok for tok, d in sessions.items() if d.get("userid") == userid]
|
| 602 |
-
|
| 603 |
-
@staticmethod
|
| 604 |
-
def clear_sessions_for_user(userid: str) -> int:
|
| 605 |
-
"""Deletes all active sessions for a specific user, forcing them to log in again."""
|
| 606 |
-
sessions = SessionManager._load()
|
| 607 |
-
tokens_to_delete = [tok for tok, d in sessions.items() if d.get("userid") == userid]
|
| 608 |
-
for tok in tokens_to_delete:
|
| 609 |
-
del sessions[tok]
|
| 610 |
-
if tokens_to_delete:
|
| 611 |
-
SessionManager._save(sessions)
|
| 612 |
-
log.info("[Auth] Cleared %d sessions for '%s'", len(tokens_to_delete), userid)
|
| 613 |
-
|
| 614 |
-
# Always force offline status to resolve "ghost" logins
|
| 615 |
-
UserDatabase.update_user(userid, {"login_status": "offline"})
|
| 616 |
-
return len(tokens_to_delete)
|
| 617 |
-
|
| 618 |
-
@staticmethod
|
| 619 |
-
def cleanup_expired() -> int:
|
| 620 |
-
sessions = SessionManager._load()
|
| 621 |
-
expired = [
|
| 622 |
-
tok for tok, d in sessions.items()
|
| 623 |
-
if (datetime.now() - datetime.fromisoformat(d["created_at"])).total_seconds()
|
| 624 |
-
> SESSION_EXPIRATION_HOURS * 3600
|
| 625 |
-
]
|
| 626 |
-
for tok in expired:
|
| 627 |
-
del sessions[tok]
|
| 628 |
-
if expired:
|
| 629 |
-
SessionManager._save(sessions)
|
| 630 |
-
return len(expired)
|
| 631 |
-
|
| 632 |
-
@staticmethod
|
| 633 |
-
def invalidate_all_sessions_on_startup() -> int:
|
| 634 |
-
"""
|
| 635 |
-
Invalidates all active session tokens and updates all users as offline
|
| 636 |
-
on application startup to enforce fresh per-session logins.
|
| 637 |
-
"""
|
| 638 |
-
sessions = SessionManager._load()
|
| 639 |
-
count = len(sessions)
|
| 640 |
-
SessionManager._save({})
|
| 641 |
-
|
| 642 |
-
# Update all users to offline status and ensure admin preserves is_admin
|
| 643 |
-
data = UserDatabase.load()
|
| 644 |
-
users = data.get("users", {})
|
| 645 |
-
updated = False
|
| 646 |
-
for userid, user_info in users.items():
|
| 647 |
-
if user_info.get("login_status") != "offline":
|
| 648 |
-
user_info["login_status"] = "offline"
|
| 649 |
-
updated = True
|
| 650 |
-
if userid == "admin" and not user_info.get("is_admin"):
|
| 651 |
-
user_info["is_admin"] = True
|
| 652 |
-
updated = True
|
| 653 |
-
if updated:
|
| 654 |
-
UserDatabase.save(data)
|
| 655 |
-
|
| 656 |
-
log.info("[Auth Startup] Invalidated %d active sessions. Updated users to offline status.", count)
|
| 657 |
-
return count
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
def invalidate_all_sessions_on_startup() -> int:
|
| 661 |
-
return SessionManager.invalidate_all_sessions_on_startup()
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
# ---------------------------------------------------------------------------
|
| 666 |
-
# Activity Logger
|
| 667 |
-
# ---------------------------------------------------------------------------
|
| 668 |
-
|
| 669 |
-
class ActivityLogger:
|
| 670 |
-
"""Logs user activities for audit trail (parity with mlbench)."""
|
| 671 |
-
|
| 672 |
-
@staticmethod
|
| 673 |
-
def log_activity(userid: str, action: str, status: str, details: str = "", duration_seconds: Optional[int] = None):
|
| 674 |
-
_check_init()
|
| 675 |
-
try:
|
| 676 |
-
log_file = _SECURITY_DIR / "activity_log.jsonl"
|
| 677 |
-
log_entry = {
|
| 678 |
-
'timestamp': datetime.now().isoformat(),
|
| 679 |
-
'userid': userid,
|
| 680 |
-
'action': action,
|
| 681 |
-
'status': status,
|
| 682 |
-
'details': details
|
| 683 |
-
}
|
| 684 |
-
if duration_seconds is not None:
|
| 685 |
-
log_entry['duration_seconds'] = duration_seconds
|
| 686 |
-
|
| 687 |
-
with open(log_file, 'a') as f:
|
| 688 |
-
f.write(json.dumps(log_entry) + '\n')
|
| 689 |
-
log.info("[Activity] Logged: %s - %s - %s", userid, action, status)
|
| 690 |
-
if _HF_SYNC:
|
| 691 |
-
_HF_SYNC.push_file_async(log_file)
|
| 692 |
-
except Exception as e:
|
| 693 |
-
log.error("[Activity] Error logging activity: %s", e)
|
| 694 |
-
|
| 695 |
-
@staticmethod
|
| 696 |
-
def get_activity_logs(userid: Optional[str] = None, limit: int = 1000) -> list:
|
| 697 |
-
_check_init()
|
| 698 |
-
try:
|
| 699 |
-
log_file = _SECURITY_DIR / "activity_log.jsonl"
|
| 700 |
-
logs = []
|
| 701 |
-
if log_file.exists():
|
| 702 |
-
with open(log_file, 'r') as f:
|
| 703 |
-
for line in f:
|
| 704 |
-
if line.strip():
|
| 705 |
-
log_entry = json.loads(line)
|
| 706 |
-
if userid is None or log_entry.get('userid') == userid:
|
| 707 |
-
logs.append(log_entry)
|
| 708 |
-
return logs[-limit:]
|
| 709 |
-
except Exception as e:
|
| 710 |
-
log.error("[Activity] Error reading logs: %s", e)
|
| 711 |
-
return []
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
# ---------------------------------------------------------------------------
|
| 715 |
-
# Encrypted User Data Sync in users.db
|
| 716 |
-
# ---------------------------------------------------------------------------
|
| 717 |
-
|
| 718 |
-
def backup_session_to_db(userid: str, base_dir: Path | str) -> bool:
|
| 719 |
-
"""Read the session files (config, state, watchlists, portfolio) and save encrypted to users.db."""
|
| 720 |
-
_check_init()
|
| 721 |
-
if userid == "admin":
|
| 722 |
-
return True # Admin uses global shared files, not isolated/encrypted
|
| 723 |
-
|
| 724 |
-
from ..config import Paths
|
| 725 |
-
paths = Paths(base_dir=base_dir, session_token=userid)
|
| 726 |
-
|
| 727 |
-
# Read files
|
| 728 |
-
try:
|
| 729 |
-
state_data = None
|
| 730 |
-
if paths.state_file.exists():
|
| 731 |
-
try:
|
| 732 |
-
state_data = json.loads(paths.state_file.read_text())
|
| 733 |
-
except Exception:
|
| 734 |
-
pass
|
| 735 |
-
|
| 736 |
-
config_data = None
|
| 737 |
-
if paths.config_file.exists():
|
| 738 |
-
try:
|
| 739 |
-
config_data = json.loads(paths.config_file.read_text())
|
| 740 |
-
except Exception:
|
| 741 |
-
pass
|
| 742 |
-
|
| 743 |
-
portfolio_data = None
|
| 744 |
-
if paths.portfolio_file.exists():
|
| 745 |
-
try:
|
| 746 |
-
# Read binary Parquet file and encode to base64 string
|
| 747 |
-
binary_data = paths.portfolio_file.read_bytes()
|
| 748 |
-
portfolio_data = base64.b64encode(binary_data).decode('utf-8')
|
| 749 |
-
except Exception as exc:
|
| 750 |
-
log.error("[Auth] Failed to backup portfolio Parquet: %s", exc)
|
| 751 |
-
|
| 752 |
-
watchlists_data = {}
|
| 753 |
-
if paths.watchlist_dir.exists():
|
| 754 |
-
for item in paths.watchlist_dir.iterdir():
|
| 755 |
-
if item.is_file() and item.suffix == ".json":
|
| 756 |
-
try:
|
| 757 |
-
watchlists_data[item.name] = json.loads(item.read_text())
|
| 758 |
-
except Exception:
|
| 759 |
-
pass
|
| 760 |
-
|
| 761 |
-
user_data = {
|
| 762 |
-
"state": state_data,
|
| 763 |
-
"config": config_data,
|
| 764 |
-
"portfolio": portfolio_data,
|
| 765 |
-
"watchlists": watchlists_data,
|
| 766 |
-
}
|
| 767 |
-
|
| 768 |
-
# Encrypt & save
|
| 769 |
-
plain = json.dumps(user_data)
|
| 770 |
-
enc = EncryptionManager.encrypt(plain)
|
| 771 |
-
UserDatabase.update_user(userid, {"user_data_encrypted": enc})
|
| 772 |
-
return True
|
| 773 |
-
except Exception as exc:
|
| 774 |
-
log.error("[Auth] Failed to backup session files for user %s: %s", userid, exc)
|
| 775 |
-
return False
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
def restore_session_from_db(userid: str, base_dir: Path | str) -> bool:
|
| 779 |
-
"""Load and decrypt the user's data from users.db and write to the isolated session files."""
|
| 780 |
-
_check_init()
|
| 781 |
-
if userid == "admin":
|
| 782 |
-
return False # Admin uses global shared files, no restore needed
|
| 783 |
-
|
| 784 |
-
user = UserDatabase.get_user(userid)
|
| 785 |
-
if not user:
|
| 786 |
-
return False
|
| 787 |
-
|
| 788 |
-
enc = user.get("user_data_encrypted")
|
| 789 |
-
if not enc:
|
| 790 |
-
return False # No data saved yet
|
| 791 |
-
|
| 792 |
-
try:
|
| 793 |
-
plain = EncryptionManager.decrypt(enc)
|
| 794 |
-
if not plain:
|
| 795 |
-
return False
|
| 796 |
-
|
| 797 |
-
user_data = json.loads(plain)
|
| 798 |
-
from ..config import Paths
|
| 799 |
-
paths = Paths(base_dir=base_dir, session_token=userid)
|
| 800 |
-
paths.ensure_dirs()
|
| 801 |
-
|
| 802 |
-
# Restore state
|
| 803 |
-
if user_data.get("state"):
|
| 804 |
-
paths.state_file.write_text(json.dumps(user_data["state"], indent=2))
|
| 805 |
-
|
| 806 |
-
# Restore config
|
| 807 |
-
if user_data.get("config"):
|
| 808 |
-
paths.config_file.write_text(json.dumps(user_data["config"], indent=2))
|
| 809 |
-
|
| 810 |
-
# Restore portfolio
|
| 811 |
-
if user_data.get("portfolio"):
|
| 812 |
-
try:
|
| 813 |
-
# Decode base64 to binary bytes and save
|
| 814 |
-
port_raw = user_data["portfolio"]
|
| 815 |
-
try:
|
| 816 |
-
binary_data = base64.b64decode(port_raw.encode('utf-8'))
|
| 817 |
-
paths.portfolio_file.write_bytes(binary_data)
|
| 818 |
-
except Exception:
|
| 819 |
-
# Fallback to legacy plain text write if it was not base64
|
| 820 |
-
paths.portfolio_file.write_text(port_raw)
|
| 821 |
-
except Exception as exc:
|
| 822 |
-
log.error("[Auth] Failed to restore portfolio Parquet: %s", exc)
|
| 823 |
-
|
| 824 |
-
# Restore watchlists
|
| 825 |
-
if user_data.get("watchlists"):
|
| 826 |
-
for filename, wl_content in user_data["watchlists"].items():
|
| 827 |
-
file_path = paths.watchlist_dir / filename
|
| 828 |
-
file_path.write_text(json.dumps(wl_content, indent=2))
|
| 829 |
-
|
| 830 |
-
log.info("[Auth] Restored session files from users.db for user %s", userid)
|
| 831 |
-
return True
|
| 832 |
-
except Exception as exc:
|
| 833 |
-
log.error("[Auth] Failed to restore session files for user %s: %s", userid, exc)
|
| 834 |
-
return False
|
| 835 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/userdata_db.py
DELETED
|
@@ -1,206 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.userdata_db
|
| 3 |
-
========================
|
| 4 |
-
Encrypted SQLite database module (userdata.db) for per-user private Watchlists 1–5.
|
| 5 |
-
Uses Fernet AES-256 encryption via EncryptionManager to encrypt watchlist contents.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import json
|
| 11 |
-
import logging
|
| 12 |
-
import sqlite3
|
| 13 |
-
from datetime import datetime
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
from typing import Any, Dict, List, Optional
|
| 16 |
-
|
| 17 |
-
log = logging.getLogger(__name__)
|
| 18 |
-
|
| 19 |
-
_USERDATA_DB_FILE: Path | None = None
|
| 20 |
-
_SECURITY_DIR: Path | None = None
|
| 21 |
-
_HF_SYNC = None
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def init(base_dir: Path | str, hf_sync=None) -> None:
|
| 25 |
-
"""Initialise SQLite database paths for userdata.db."""
|
| 26 |
-
global _USERDATA_DB_FILE, _SECURITY_DIR, _HF_SYNC
|
| 27 |
-
base_dir = Path(base_dir)
|
| 28 |
-
_SECURITY_DIR = base_dir / "processed" / "security"
|
| 29 |
-
_SECURITY_DIR.mkdir(parents=True, exist_ok=True)
|
| 30 |
-
_USERDATA_DB_FILE = _SECURITY_DIR / "userdata.db"
|
| 31 |
-
_HF_SYNC = hf_sync
|
| 32 |
-
|
| 33 |
-
UserDataDB.ensure_tables()
|
| 34 |
-
UserDataDB.perform_initial_reset_and_migration(base_dir)
|
| 35 |
-
log.info("[UserDataDB] Initialised encrypted SQLite database: %s", _USERDATA_DB_FILE)
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
class UserDataDB:
|
| 39 |
-
"""Encrypted SQLite database manager for per-user private watchlists (slots 1..5)."""
|
| 40 |
-
|
| 41 |
-
@staticmethod
|
| 42 |
-
def _get_connection() -> sqlite3.Connection:
|
| 43 |
-
if _USERDATA_DB_FILE is None:
|
| 44 |
-
raise RuntimeError("userdata_db.init(base_dir) must be called first")
|
| 45 |
-
conn = sqlite3.connect(_USERDATA_DB_FILE)
|
| 46 |
-
conn.row_factory = sqlite3.Row
|
| 47 |
-
return conn
|
| 48 |
-
|
| 49 |
-
@staticmethod
|
| 50 |
-
def ensure_tables() -> None:
|
| 51 |
-
"""Create user_watchlists table if it does not exist."""
|
| 52 |
-
conn = UserDataDB._get_connection()
|
| 53 |
-
try:
|
| 54 |
-
with conn:
|
| 55 |
-
conn.execute("""
|
| 56 |
-
CREATE TABLE IF NOT EXISTS user_watchlists (
|
| 57 |
-
userid TEXT NOT NULL,
|
| 58 |
-
slot INTEGER NOT NULL,
|
| 59 |
-
name TEXT NOT NULL,
|
| 60 |
-
encrypted_data TEXT NOT NULL,
|
| 61 |
-
updated_at TEXT NOT NULL,
|
| 62 |
-
PRIMARY KEY (userid, slot)
|
| 63 |
-
);
|
| 64 |
-
""")
|
| 65 |
-
log.info("[UserDataDB] Schema verified for user_watchlists table in userdata.db.")
|
| 66 |
-
finally:
|
| 67 |
-
conn.close()
|
| 68 |
-
|
| 69 |
-
@staticmethod
|
| 70 |
-
def save_watchlist(userid: str, slot: int, name: str, symbols: List[str]) -> bool:
|
| 71 |
-
"""Encrypt and save a user's private watchlist (slots 1..5)."""
|
| 72 |
-
if slot < 1 or slot > 5:
|
| 73 |
-
log.warning("[UserDataDB] Slot %d is outside private range 1..5 for user %s", slot, userid)
|
| 74 |
-
return False
|
| 75 |
-
|
| 76 |
-
from .user_auth import EncryptionManager
|
| 77 |
-
payload = {
|
| 78 |
-
"name": name or f"Watchlist {slot}",
|
| 79 |
-
"symbols": list(symbols or [])
|
| 80 |
-
}
|
| 81 |
-
plain_json = json.dumps(payload)
|
| 82 |
-
encrypted_data = EncryptionManager.encrypt(plain_json)
|
| 83 |
-
now_iso = datetime.now().isoformat()
|
| 84 |
-
|
| 85 |
-
conn = UserDataDB._get_connection()
|
| 86 |
-
try:
|
| 87 |
-
with conn:
|
| 88 |
-
conn.execute("""
|
| 89 |
-
INSERT INTO user_watchlists (userid, slot, name, encrypted_data, updated_at)
|
| 90 |
-
VALUES (?, ?, ?, ?, ?)
|
| 91 |
-
ON CONFLICT(userid, slot) DO UPDATE SET
|
| 92 |
-
name = excluded.name,
|
| 93 |
-
encrypted_data = excluded.encrypted_data,
|
| 94 |
-
updated_at = excluded.updated_at;
|
| 95 |
-
""", (userid, slot, name or f"Watchlist {slot}", encrypted_data, now_iso))
|
| 96 |
-
log.info("[UserDataDB] Saved encrypted watchlist slot %d for user %s (%d symbols)", slot, userid, len(symbols))
|
| 97 |
-
if _HF_SYNC and _USERDATA_DB_FILE:
|
| 98 |
-
_HF_SYNC.push_file_async(_USERDATA_DB_FILE)
|
| 99 |
-
return True
|
| 100 |
-
except Exception as exc:
|
| 101 |
-
log.error("[UserDataDB] Error saving watchlist for user %s slot %d: %s", userid, slot, exc, exc_info=True)
|
| 102 |
-
return False
|
| 103 |
-
finally:
|
| 104 |
-
conn.close()
|
| 105 |
-
|
| 106 |
-
@staticmethod
|
| 107 |
-
def get_watchlist(userid: str, slot: int) -> Optional[Dict[str, Any]]:
|
| 108 |
-
"""Fetch and decrypt a user's private watchlist (slots 1..5)."""
|
| 109 |
-
if slot < 1 or slot > 5:
|
| 110 |
-
return None
|
| 111 |
-
|
| 112 |
-
from .user_auth import EncryptionManager
|
| 113 |
-
conn = UserDataDB._get_connection()
|
| 114 |
-
try:
|
| 115 |
-
cur = conn.execute(
|
| 116 |
-
"SELECT name, encrypted_data FROM user_watchlists WHERE userid = ? AND slot = ?",
|
| 117 |
-
(userid, slot)
|
| 118 |
-
)
|
| 119 |
-
row = cur.fetchone()
|
| 120 |
-
if not row:
|
| 121 |
-
return None
|
| 122 |
-
|
| 123 |
-
name = row["name"]
|
| 124 |
-
enc_data = row["encrypted_data"]
|
| 125 |
-
plain_json = EncryptionManager.decrypt(enc_data)
|
| 126 |
-
if not plain_json:
|
| 127 |
-
log.warning("[UserDataDB] Decryption returned empty string for user %s slot %d", userid, slot)
|
| 128 |
-
return {"name": name, "symbols": []}
|
| 129 |
-
|
| 130 |
-
data = json.loads(plain_json)
|
| 131 |
-
return {
|
| 132 |
-
"name": data.get("name", name),
|
| 133 |
-
"symbols": data.get("symbols", [])
|
| 134 |
-
}
|
| 135 |
-
except Exception as exc:
|
| 136 |
-
log.error("[UserDataDB] Error fetching watchlist for user %s slot %d: %s", userid, slot, exc, exc_info=True)
|
| 137 |
-
return None
|
| 138 |
-
finally:
|
| 139 |
-
conn.close()
|
| 140 |
-
|
| 141 |
-
@staticmethod
|
| 142 |
-
def get_all_user_watchlists(userid: str) -> Dict[int, Dict[str, Any]]:
|
| 143 |
-
"""Return all watchlists 1..5 for userid."""
|
| 144 |
-
result = {}
|
| 145 |
-
for slot in range(1, 6):
|
| 146 |
-
wl = UserDataDB.get_watchlist(userid, slot)
|
| 147 |
-
if wl:
|
| 148 |
-
result[slot] = wl
|
| 149 |
-
else:
|
| 150 |
-
result[slot] = {"name": f"Watchlist {slot}", "symbols": []}
|
| 151 |
-
return result
|
| 152 |
-
|
| 153 |
-
@staticmethod
|
| 154 |
-
def reset_watchlists_except(allowed_users: List[str] = None) -> int:
|
| 155 |
-
"""
|
| 156 |
-
Resets Watchlists 1–5 to blank (empty symbols list) for all users
|
| 157 |
-
EXCEPT those listed in allowed_users (default: ['admin', 'sam']).
|
| 158 |
-
"""
|
| 159 |
-
allowed = set(allowed_users or ["admin", "sam"])
|
| 160 |
-
from .user_auth import UserDatabase
|
| 161 |
-
all_users = UserDatabase.get_all_users()
|
| 162 |
-
|
| 163 |
-
reset_count = 0
|
| 164 |
-
for uid in all_users.keys():
|
| 165 |
-
if uid not in allowed:
|
| 166 |
-
for slot in range(1, 6):
|
| 167 |
-
UserDataDB.save_watchlist(uid, slot, f"Watchlist {slot}", [])
|
| 168 |
-
reset_count += 1
|
| 169 |
-
return reset_count
|
| 170 |
-
|
| 171 |
-
@staticmethod
|
| 172 |
-
def perform_initial_reset_and_migration(base_dir: Path | str) -> None:
|
| 173 |
-
"""
|
| 174 |
-
Migrates existing Watchlists 1..5 for admin and sam into userdata.db (if not present),
|
| 175 |
-
and resets Watchlists 1..5 to blank for all other users.
|
| 176 |
-
"""
|
| 177 |
-
from .user_auth import UserDatabase
|
| 178 |
-
all_users = UserDatabase.get_all_users()
|
| 179 |
-
base_dir = Path(base_dir)
|
| 180 |
-
wl_dir = base_dir / "processed" / "watchlists"
|
| 181 |
-
|
| 182 |
-
for uid in all_users.keys():
|
| 183 |
-
if uid in ("admin", "sam"):
|
| 184 |
-
# Migrate existing JSON files for admin / sam if slot not yet populated in userdata.db
|
| 185 |
-
user_wl_dir = wl_dir / f"session_{uid}" if uid != "admin" else wl_dir
|
| 186 |
-
for slot in range(1, 6):
|
| 187 |
-
existing_wl = UserDataDB.get_watchlist(uid, slot)
|
| 188 |
-
if existing_wl is None:
|
| 189 |
-
json_file = user_wl_dir / f"nse_dashboard_watchlist{slot}.json"
|
| 190 |
-
name = f"Watchlist {slot}"
|
| 191 |
-
symbols = []
|
| 192 |
-
if json_file.exists():
|
| 193 |
-
try:
|
| 194 |
-
data = json.loads(json_file.read_text())
|
| 195 |
-
name = data.get("name", name)
|
| 196 |
-
symbols_raw = data.get("symbols", [])
|
| 197 |
-
symbols = [s.get("SYMBOL") if isinstance(s, dict) else str(s) for s in symbols_raw]
|
| 198 |
-
except Exception:
|
| 199 |
-
pass
|
| 200 |
-
UserDataDB.save_watchlist(uid, slot, name, symbols)
|
| 201 |
-
log.info("[UserDataDB] Migrated slot %d for '%s' to userdata.db (%d symbols)", slot, uid, len(symbols))
|
| 202 |
-
else:
|
| 203 |
-
# Reset slots 1..5 to blank for non-exempt users
|
| 204 |
-
for slot in range(1, 6):
|
| 205 |
-
UserDataDB.save_watchlist(uid, slot, f"Watchlist {slot}", [])
|
| 206 |
-
log.info("[UserDataDB] Reset Watchlists 1–5 to blank for user '%s'", uid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/core/watchlists.py
DELETED
|
@@ -1,262 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.core.watchlists
|
| 3 |
-
=======================
|
| 4 |
-
Watchlist persistence: 20 named slots, the default dashboard state file,
|
| 5 |
-
analysis-cache watchlists, and application config. Functional parity with
|
| 6 |
-
the v4.5 methods ``_save_watchlist_json``, ``_load_symbols_from_json``,
|
| 7 |
-
``_load_analysis_json_if_recent``, ``load_watchlist_names``,
|
| 8 |
-
``save_dashboard_state``, ``load_config`` and ``save_config``.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import json
|
| 14 |
-
import logging
|
| 15 |
-
from datetime import datetime
|
| 16 |
-
from pathlib import Path
|
| 17 |
-
from typing import TYPE_CHECKING
|
| 18 |
-
|
| 19 |
-
import pandas as pd
|
| 20 |
-
|
| 21 |
-
from ..config import WATCHLIST_SLOTS, Paths
|
| 22 |
-
|
| 23 |
-
if TYPE_CHECKING:
|
| 24 |
-
from .hf_sync import HFDataSync, _NoOpSync
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
class WatchlistStore:
|
| 28 |
-
|
| 29 |
-
def __init__(self, paths: Paths, hf_sync=None):
|
| 30 |
-
self.paths = paths
|
| 31 |
-
# hf_sync is a HFDataSync or _NoOpSync — push_file_async is always safe to call
|
| 32 |
-
self._hf = hf_sync
|
| 33 |
-
|
| 34 |
-
def _backup(self) -> None:
|
| 35 |
-
if self.paths.session_token:
|
| 36 |
-
from . import user_auth
|
| 37 |
-
user_auth.backup_session_to_db(self.paths.session_token, self.paths.base_dir)
|
| 38 |
-
|
| 39 |
-
# ------------------------------------------------------------------
|
| 40 |
-
# Low-level JSON helpers (parity with StockData._* in v4.5)
|
| 41 |
-
# ------------------------------------------------------------------
|
| 42 |
-
|
| 43 |
-
def save_watchlist_json(self, df_to_save: pd.DataFrame,
|
| 44 |
-
file_path: Path, name: str) -> None:
|
| 45 |
-
"""Save a DataFrame as a watchlist JSON with name and timestamp."""
|
| 46 |
-
watchlist_data = {
|
| 47 |
-
"name": name,
|
| 48 |
-
"symbols": [
|
| 49 |
-
{"SYMBOL": row['SYMBOL'], "Quantity": 0.0, "Price": 0.0}
|
| 50 |
-
for _, row in df_to_save.iterrows()
|
| 51 |
-
],
|
| 52 |
-
"last_updated": datetime.now().isoformat(),
|
| 53 |
-
}
|
| 54 |
-
try:
|
| 55 |
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 56 |
-
with open(file_path, 'w') as f:
|
| 57 |
-
json.dump(watchlist_data, f, indent=4)
|
| 58 |
-
print(f"[ACTIVITY] Session watchlist JSON updated: {file_path.name}", flush=True)
|
| 59 |
-
logging.info(f"Saved '{name}' watchlist to {file_path}")
|
| 60 |
-
if self._hf:
|
| 61 |
-
self._hf.push_file_async(file_path)
|
| 62 |
-
self._backup()
|
| 63 |
-
except Exception as e:
|
| 64 |
-
logging.error(f"Error saving '{name}' watchlist to {file_path}: {e}")
|
| 65 |
-
|
| 66 |
-
def load_symbols_from_json(self, file_path: Path,
|
| 67 |
-
current_data_tran_date=None
|
| 68 |
-
) -> tuple[str, list[dict]]:
|
| 69 |
-
"""
|
| 70 |
-
Load symbol entries from a watchlist JSON file.
|
| 71 |
-
Returns (watchlist_name, [{'SYMBOL','Quantity','Price'}, ...]).
|
| 72 |
-
If ``current_data_tran_date`` is provided and the file's
|
| 73 |
-
``last_updated`` predates it, returns a cache miss (name, []).
|
| 74 |
-
Returns ("", []) on error.
|
| 75 |
-
"""
|
| 76 |
-
file_path = Path(file_path)
|
| 77 |
-
import re
|
| 78 |
-
m = re.search(r"nse_dashboard_watchlist([1-5])\.json$", file_path.name)
|
| 79 |
-
if m:
|
| 80 |
-
slot = int(m.group(1))
|
| 81 |
-
userid = self.paths.session_token or "admin"
|
| 82 |
-
from .userdata_db import UserDataDB
|
| 83 |
-
wl = UserDataDB.get_watchlist(userid, slot)
|
| 84 |
-
if wl is not None:
|
| 85 |
-
symbols = [{"SYMBOL": s, "Quantity": 0.0, "Price": 0.0} for s in wl.get("symbols", [])]
|
| 86 |
-
return wl.get("name", f"Watchlist {slot}"), symbols
|
| 87 |
-
|
| 88 |
-
if not file_path.exists():
|
| 89 |
-
logging.info(f"JSON file not found: {file_path}")
|
| 90 |
-
return "", []
|
| 91 |
-
try:
|
| 92 |
-
with open(file_path, 'r') as f:
|
| 93 |
-
data = json.load(f)
|
| 94 |
-
watchlist_name = data.get('name', file_path.stem)
|
| 95 |
-
if current_data_tran_date:
|
| 96 |
-
last_updated_str = data.get('last_updated')
|
| 97 |
-
if not last_updated_str:
|
| 98 |
-
logging.warning(
|
| 99 |
-
f"'{file_path}' has no 'last_updated' field. Recalculating."
|
| 100 |
-
)
|
| 101 |
-
return watchlist_name, []
|
| 102 |
-
last_updated_date = datetime.fromisoformat(last_updated_str).date()
|
| 103 |
-
ref = current_data_tran_date
|
| 104 |
-
ref_date = ref.date() if hasattr(ref, 'date') else ref
|
| 105 |
-
if last_updated_date < ref_date:
|
| 106 |
-
logging.info(f"'{file_path}' is outdated. Recalculating.")
|
| 107 |
-
return watchlist_name, []
|
| 108 |
-
symbols_data = []
|
| 109 |
-
for entry in data.get('symbols', []):
|
| 110 |
-
if isinstance(entry, dict) and 'SYMBOL' in entry:
|
| 111 |
-
symbols_data.append({
|
| 112 |
-
"SYMBOL": entry['SYMBOL'],
|
| 113 |
-
"Quantity": entry.get('Quantity', 0.0),
|
| 114 |
-
"Price": entry.get('Price', 0.0),
|
| 115 |
-
})
|
| 116 |
-
elif isinstance(entry, str):
|
| 117 |
-
symbols_data.append(
|
| 118 |
-
{"SYMBOL": entry, "Quantity": 0.0, "Price": 0.0}
|
| 119 |
-
)
|
| 120 |
-
else:
|
| 121 |
-
logging.warning(f"Skipping invalid entry in JSON file: {entry}")
|
| 122 |
-
return watchlist_name, symbols_data
|
| 123 |
-
except (json.JSONDecodeError, ValueError) as e:
|
| 124 |
-
logging.warning(f"Error parsing '{file_path}': {e}.")
|
| 125 |
-
return "", []
|
| 126 |
-
except Exception as e:
|
| 127 |
-
logging.error(f"Unexpected error loading JSON from {file_path}: {e}.")
|
| 128 |
-
return "", []
|
| 129 |
-
|
| 130 |
-
def load_analysis_json_if_recent(self, file_path: Path,
|
| 131 |
-
current_data_tran_date) -> list[str] | None:
|
| 132 |
-
"""Symbol list from an analysis-cache watchlist if fresh, else None."""
|
| 133 |
-
_name, loaded = self.load_symbols_from_json(file_path, current_data_tran_date)
|
| 134 |
-
if loaded:
|
| 135 |
-
return [item['SYMBOL'] for item in loaded]
|
| 136 |
-
return None
|
| 137 |
-
|
| 138 |
-
# ------------------------------------------------------------------
|
| 139 |
-
# Named slots (1..20)
|
| 140 |
-
# ------------------------------------------------------------------
|
| 141 |
-
|
| 142 |
-
def load_watchlist_names(self) -> list[str]:
|
| 143 |
-
"""Read watchlist names from slot files and UserDataDB for slots 1..5."""
|
| 144 |
-
names = [f"Watchlist {i}" for i in range(1, WATCHLIST_SLOTS + 1)]
|
| 145 |
-
userid = self.paths.session_token or "admin"
|
| 146 |
-
from .userdata_db import UserDataDB
|
| 147 |
-
|
| 148 |
-
for i in range(WATCHLIST_SLOTS):
|
| 149 |
-
slot = i + 1
|
| 150 |
-
if 1 <= slot <= 5:
|
| 151 |
-
wl = UserDataDB.get_watchlist(userid, slot)
|
| 152 |
-
if wl and wl.get("name"):
|
| 153 |
-
names[i] = wl["name"]
|
| 154 |
-
continue
|
| 155 |
-
file_path = self.paths.watchlist_slot(slot)
|
| 156 |
-
try:
|
| 157 |
-
with open(file_path, "r") as f:
|
| 158 |
-
data = json.load(f)
|
| 159 |
-
names[i] = data.get("name", names[i])
|
| 160 |
-
except (FileNotFoundError, json.JSONDecodeError):
|
| 161 |
-
continue
|
| 162 |
-
return names
|
| 163 |
-
|
| 164 |
-
def save_slot(self, slot: int, name: str, symbols: list[dict]) -> Path:
|
| 165 |
-
"""Write a full watchlist to a numbered slot and UserDataDB if slot 1..5."""
|
| 166 |
-
if not (1 <= slot <= WATCHLIST_SLOTS):
|
| 167 |
-
raise ValueError(f"Watchlist slot must be 1..{WATCHLIST_SLOTS}")
|
| 168 |
-
file_path = self.paths.watchlist_slot(slot)
|
| 169 |
-
with open(file_path, "w") as f:
|
| 170 |
-
json.dump({
|
| 171 |
-
"name": name,
|
| 172 |
-
"symbols": symbols,
|
| 173 |
-
"last_updated": datetime.now().isoformat(),
|
| 174 |
-
}, f, indent=2)
|
| 175 |
-
|
| 176 |
-
if 1 <= slot <= 5:
|
| 177 |
-
userid = self.paths.session_token or "admin"
|
| 178 |
-
from .userdata_db import UserDataDB
|
| 179 |
-
sym_list = [s.get("SYMBOL") if isinstance(s, dict) else str(s) for s in symbols]
|
| 180 |
-
UserDataDB.save_watchlist(userid, slot, name, sym_list)
|
| 181 |
-
|
| 182 |
-
print(f"[ACTIVITY] Session watchlist slot {slot} updated: {file_path.name}", flush=True)
|
| 183 |
-
if self._hf:
|
| 184 |
-
self._hf.push_file_async(file_path)
|
| 185 |
-
self._backup()
|
| 186 |
-
return file_path
|
| 187 |
-
|
| 188 |
-
def add_symbols_to_slot(self, slot: int, symbols: list[str]) -> tuple[str, int]:
|
| 189 |
-
"""
|
| 190 |
-
Merge symbols into a numbered slot (dedup). Returns
|
| 191 |
-
(watchlist_name, number_of_new_symbols_added).
|
| 192 |
-
"""
|
| 193 |
-
file_path = self.paths.watchlist_slot(slot)
|
| 194 |
-
existing_name, existing = self.load_symbols_from_json(file_path)
|
| 195 |
-
watchlist_name = existing_name or f"Watchlist {slot}"
|
| 196 |
-
existing_set = {e['SYMBOL'] for e in existing if 'SYMBOL' in e}
|
| 197 |
-
added = 0
|
| 198 |
-
for s in symbols:
|
| 199 |
-
if s not in existing_set:
|
| 200 |
-
existing.append({"SYMBOL": s, "Quantity": 0.0, "Price": 0.0})
|
| 201 |
-
existing_set.add(s)
|
| 202 |
-
added += 1
|
| 203 |
-
if added > 0:
|
| 204 |
-
self.save_slot(slot, watchlist_name, existing)
|
| 205 |
-
return watchlist_name, added
|
| 206 |
-
|
| 207 |
-
# ------------------------------------------------------------------
|
| 208 |
-
# Dashboard state + config
|
| 209 |
-
# ------------------------------------------------------------------
|
| 210 |
-
|
| 211 |
-
def save_dashboard_state(self, cells: list[dict]) -> None:
|
| 212 |
-
"""Auto-save current stocks to the main state file between sessions."""
|
| 213 |
-
try:
|
| 214 |
-
state = {
|
| 215 |
-
'symbols': cells,
|
| 216 |
-
'last_updated': datetime.now().isoformat(),
|
| 217 |
-
}
|
| 218 |
-
with open(self.paths.state_file, 'w') as f:
|
| 219 |
-
json.dump(state, f, indent=2)
|
| 220 |
-
print(f"[ACTIVITY] Session dashboard state file updated: {self.paths.state_file.name}", flush=True)
|
| 221 |
-
if self._hf:
|
| 222 |
-
self._hf.push_file_async(self.paths.state_file)
|
| 223 |
-
self._backup()
|
| 224 |
-
except Exception as e:
|
| 225 |
-
logging.error(f"Failed to save main dashboard state file: {e}")
|
| 226 |
-
|
| 227 |
-
def load_config(self) -> dict:
|
| 228 |
-
"""Load persisted UI config (window geometry kept for compatibility)."""
|
| 229 |
-
defaults = {
|
| 230 |
-
'window_size': '1280x800',
|
| 231 |
-
'window_position': '+100+100',
|
| 232 |
-
'current_watchlist_name': "Default List",
|
| 233 |
-
}
|
| 234 |
-
try:
|
| 235 |
-
with open(self.paths.config_file, 'r') as f:
|
| 236 |
-
config = json.load(f)
|
| 237 |
-
return {**defaults, **config}
|
| 238 |
-
except FileNotFoundError:
|
| 239 |
-
return defaults
|
| 240 |
-
except Exception as e:
|
| 241 |
-
logging.error(f"Failed to load config: {e}")
|
| 242 |
-
return defaults
|
| 243 |
-
|
| 244 |
-
def save_config(self, current_watchlist_name: str,
|
| 245 |
-
window_size: str = '1280x800',
|
| 246 |
-
window_position: str = '+100+100') -> None:
|
| 247 |
-
"""Persist config. Geometry fields retained for file compatibility."""
|
| 248 |
-
try:
|
| 249 |
-
config = {
|
| 250 |
-
'window_size': window_size,
|
| 251 |
-
'window_position': window_position,
|
| 252 |
-
'last_updated': datetime.now().isoformat(),
|
| 253 |
-
'current_watchlist_name': current_watchlist_name,
|
| 254 |
-
}
|
| 255 |
-
with open(self.paths.config_file, 'w') as f:
|
| 256 |
-
json.dump(config, f, indent=2)
|
| 257 |
-
print(f"[ACTIVITY] Session configuration file updated: {self.paths.config_file.name}", flush=True)
|
| 258 |
-
if self._hf:
|
| 259 |
-
self._hf.push_file_async(self.paths.config_file)
|
| 260 |
-
self._backup()
|
| 261 |
-
except Exception as e:
|
| 262 |
-
logging.error(f"Failed to save config: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/crew/__init__.py
DELETED
|
File without changes
|
nsedash/crew/agents.py
DELETED
|
@@ -1,221 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.crew.agents
|
| 3 |
-
===================
|
| 4 |
-
Domain agents. Each agent's tools are deterministic Python callables over
|
| 5 |
-
the core services — no LLM. The analysis tools reproduce, byte-for-byte in
|
| 6 |
-
behaviour, the compute paths of v4.5's ``filter_stocks_by_change``,
|
| 7 |
-
``filter_stocks_by_volume_change``, ``filter_stocks_by_change_return`` and
|
| 8 |
-
``load_top_score_stocks``, including their JSON cache-freshness logic.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import logging
|
| 14 |
-
from datetime import datetime
|
| 15 |
-
|
| 16 |
-
from ..config import ANALYSIS_LIMIT, Paths
|
| 17 |
-
from ..core.portfolio import import_portfolio
|
| 18 |
-
from ..core.recommender_bridge import run_recommendations, train_all_models
|
| 19 |
-
from ..core.stock_data import StockData
|
| 20 |
-
from ..core.watchlists import WatchlistStore
|
| 21 |
-
from .framework import Agent, Tool
|
| 22 |
-
|
| 23 |
-
PRICE_CHANGE_COLUMNS = {
|
| 24 |
-
"1D": "DAILY_CHANGE", "1W": "WEEK_CHANGE",
|
| 25 |
-
"1M": "MONTH_CHANGE", "3M": "THREE_MONTH_CHANGE",
|
| 26 |
-
}
|
| 27 |
-
RETURN_COLUMNS = {
|
| 28 |
-
"1W": "WEEK_CHANGE", "1M": "MONTH_CHANGE", "3M": "THREE_MONTH_CHANGE",
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def build_agents(stock_data: StockData, watchlists: WatchlistStore,
|
| 33 |
-
paths: Paths) -> dict[str, Agent]:
|
| 34 |
-
"""Construct the full agent roster wired to live service instances."""
|
| 35 |
-
|
| 36 |
-
# ------------------------------------------------------------------
|
| 37 |
-
# Data steward tools
|
| 38 |
-
# ------------------------------------------------------------------
|
| 39 |
-
|
| 40 |
-
def reload_csv() -> dict:
|
| 41 |
-
stock_data.load_data()
|
| 42 |
-
return {"last_date": str(stock_data.last_date)}
|
| 43 |
-
|
| 44 |
-
def enrich_date(date) -> "object":
|
| 45 |
-
return stock_data.get_data_for_date(date)
|
| 46 |
-
|
| 47 |
-
# ------------------------------------------------------------------
|
| 48 |
-
# Market watcher tools
|
| 49 |
-
# ------------------------------------------------------------------
|
| 50 |
-
|
| 51 |
-
def live_quotes(symbols: list[str], preferred_source: str = "Google") -> dict:
|
| 52 |
-
data, source = stock_data.fetch_real_time_data(symbols, preferred_source)
|
| 53 |
-
return {"quotes": data, "source": source}
|
| 54 |
-
|
| 55 |
-
# ------------------------------------------------------------------
|
| 56 |
-
# Analyst tools (cache-first, parity with v4.5)
|
| 57 |
-
# ------------------------------------------------------------------
|
| 58 |
-
|
| 59 |
-
def price_change_analysis(duration: str, change_type: str,
|
| 60 |
-
threshold: float, selected_date) -> dict:
|
| 61 |
-
entry = paths.hist_price_files.get((duration, change_type))
|
| 62 |
-
if not entry:
|
| 63 |
-
raise ValueError("Invalid duration or change type.")
|
| 64 |
-
json_name = f"{duration} {change_type} Price Portfolio"
|
| 65 |
-
current_max = stock_data.df['TRAN_DATE'].max()
|
| 66 |
-
cached = watchlists.load_analysis_json_if_recent(entry, current_max)
|
| 67 |
-
if cached:
|
| 68 |
-
return {"symbols": cached, "json_name": json_name, "cached": True}
|
| 69 |
-
change_column = PRICE_CHANGE_COLUMNS.get(duration)
|
| 70 |
-
if not change_column:
|
| 71 |
-
raise ValueError("Invalid duration specified.")
|
| 72 |
-
data = stock_data.get_data_for_date(selected_date)
|
| 73 |
-
if data.empty:
|
| 74 |
-
return {"symbols": [], "json_name": json_name, "cached": False}
|
| 75 |
-
if change_column == "DAILY_CHANGE":
|
| 76 |
-
# 1D change is derived, matching the card's daily-change formula.
|
| 77 |
-
data = data.copy()
|
| 78 |
-
data["DAILY_CHANGE"] = (
|
| 79 |
-
(data['CLOSE_PRICE'] / data['PREV_CL_PR'] - 1) * 100
|
| 80 |
-
).where(data['PREV_CL_PR'] != 0, 0.0)
|
| 81 |
-
if change_column not in data.columns:
|
| 82 |
-
raise ValueError(f"'{change_column}' data not available.")
|
| 83 |
-
filtered = data.dropna(subset=[change_column]).copy()
|
| 84 |
-
if change_type == "Max":
|
| 85 |
-
filtered = filtered[filtered[change_column] >= threshold] \
|
| 86 |
-
.sort_values(by=change_column, ascending=False)
|
| 87 |
-
else:
|
| 88 |
-
filtered = filtered[filtered[change_column] <= threshold] \
|
| 89 |
-
.sort_values(by=change_column, ascending=True)
|
| 90 |
-
top = filtered.head(ANALYSIS_LIMIT).copy()
|
| 91 |
-
watchlists.save_watchlist_json(top, entry, json_name)
|
| 92 |
-
return {"symbols": top['SYMBOL'].tolist(),
|
| 93 |
-
"json_name": json_name, "cached": False}
|
| 94 |
-
|
| 95 |
-
def volume_change_analysis(duration: str, volume_cutoff: float) -> dict:
|
| 96 |
-
entry = paths.hist_vol_files.get(duration)
|
| 97 |
-
if not entry:
|
| 98 |
-
raise ValueError("Invalid duration specified.")
|
| 99 |
-
json_name = f"{duration} Max Volume Portfolio"
|
| 100 |
-
current_max = stock_data.df['TRAN_DATE'].max()
|
| 101 |
-
cached = watchlists.load_analysis_json_if_recent(entry, current_max)
|
| 102 |
-
if cached:
|
| 103 |
-
return {"symbols": cached, "json_name": json_name, "cached": True}
|
| 104 |
-
movers = stock_data.get_top_volume_movers(
|
| 105 |
-
duration, limit=ANALYSIS_LIMIT, volume_cutoff=volume_cutoff
|
| 106 |
-
)
|
| 107 |
-
if movers.empty:
|
| 108 |
-
return {"symbols": [], "json_name": json_name, "cached": False}
|
| 109 |
-
watchlists.save_watchlist_json(movers, entry, json_name)
|
| 110 |
-
return {"symbols": movers['SYMBOL'].tolist(),
|
| 111 |
-
"json_name": json_name, "cached": False}
|
| 112 |
-
|
| 113 |
-
def return_analysis(duration: str, selected_date) -> dict:
|
| 114 |
-
entry = paths.max_return_files.get(duration)
|
| 115 |
-
if not entry:
|
| 116 |
-
raise ValueError("Invalid duration specified.")
|
| 117 |
-
json_name = f"{duration} Max Return Portfolio"
|
| 118 |
-
current_max = stock_data.df['TRAN_DATE'].max()
|
| 119 |
-
cached = watchlists.load_analysis_json_if_recent(entry, current_max)
|
| 120 |
-
if cached:
|
| 121 |
-
return {"symbols": cached, "json_name": json_name, "cached": True}
|
| 122 |
-
change_column = RETURN_COLUMNS.get(duration)
|
| 123 |
-
if not change_column:
|
| 124 |
-
raise ValueError("Invalid duration specified.")
|
| 125 |
-
data = stock_data.get_data_for_date(selected_date)
|
| 126 |
-
if data.empty:
|
| 127 |
-
return {"symbols": [], "json_name": json_name, "cached": False}
|
| 128 |
-
if change_column not in data.columns:
|
| 129 |
-
raise ValueError(f"'{change_column}' data not available.")
|
| 130 |
-
filtered = data.dropna(subset=[change_column]).copy()
|
| 131 |
-
filtered = filtered[filtered[change_column] > 0] \
|
| 132 |
-
.sort_values(by=change_column, ascending=False)
|
| 133 |
-
top = filtered.head(ANALYSIS_LIMIT).copy()
|
| 134 |
-
watchlists.save_watchlist_json(top, entry, json_name)
|
| 135 |
-
return {"symbols": top['SYMBOL'].tolist(),
|
| 136 |
-
"json_name": json_name, "cached": False}
|
| 137 |
-
|
| 138 |
-
def top_score_analysis() -> dict:
|
| 139 |
-
entry = paths.max_score_watchlist_file
|
| 140 |
-
json_name = "Top 50 MC Score"
|
| 141 |
-
cached = watchlists.load_analysis_json_if_recent(entry, datetime.now())
|
| 142 |
-
if cached:
|
| 143 |
-
return {"symbols": cached, "json_name": json_name, "cached": True}
|
| 144 |
-
top = stock_data.get_top_mc_score_movers(limit=ANALYSIS_LIMIT)
|
| 145 |
-
if top.empty:
|
| 146 |
-
return {"symbols": [], "json_name": json_name, "cached": False}
|
| 147 |
-
watchlists.save_watchlist_json(top, entry, json_name)
|
| 148 |
-
return {"symbols": top['SYMBOL'].tolist(),
|
| 149 |
-
"json_name": json_name, "cached": False}
|
| 150 |
-
|
| 151 |
-
# ------------------------------------------------------------------
|
| 152 |
-
# Portfolio manager tools
|
| 153 |
-
# ------------------------------------------------------------------
|
| 154 |
-
|
| 155 |
-
def portfolio_import(filepath: str | None = None) -> dict:
|
| 156 |
-
holdings, unmapped, broker_name = import_portfolio(filepath, paths)
|
| 157 |
-
return {"holdings": holdings, "unmapped": unmapped, "broker_name": broker_name}
|
| 158 |
-
|
| 159 |
-
# ------------------------------------------------------------------
|
| 160 |
-
# Recommender tools
|
| 161 |
-
# ------------------------------------------------------------------
|
| 162 |
-
|
| 163 |
-
def recommend(symbols: list[str]) -> list[dict]:
|
| 164 |
-
return run_recommendations(
|
| 165 |
-
paths.recommender_dir, symbols, stock_data.symbol_history
|
| 166 |
-
)
|
| 167 |
-
|
| 168 |
-
def train_models() -> dict:
|
| 169 |
-
symbols = stock_data.get_all_symbols_from_df()
|
| 170 |
-
logging.info(f"Training LSTM models for {len(symbols)} symbols.")
|
| 171 |
-
return train_all_models(
|
| 172 |
-
paths.recommender_dir, symbols, stock_data.symbol_history
|
| 173 |
-
)
|
| 174 |
-
|
| 175 |
-
# ------------------------------------------------------------------
|
| 176 |
-
# Agent roster
|
| 177 |
-
# ------------------------------------------------------------------
|
| 178 |
-
|
| 179 |
-
return {
|
| 180 |
-
"data_steward": Agent(
|
| 181 |
-
role="Data Steward",
|
| 182 |
-
goal="Own the historical dataset: load, trim, enrich, and cache.",
|
| 183 |
-
backstory="Deterministic custodian of nse_processed_data.csv.",
|
| 184 |
-
tools=[
|
| 185 |
-
Tool("reload_csv", reload_csv, "Reload the source CSV"),
|
| 186 |
-
Tool("enrich_date", enrich_date, "Enriched frame for a date"),
|
| 187 |
-
],
|
| 188 |
-
),
|
| 189 |
-
"market_watcher": Agent(
|
| 190 |
-
role="Market Watcher",
|
| 191 |
-
goal="Fetch live quotes with Google↔Yahoo failover.",
|
| 192 |
-
backstory="Scrapes real-time prices concurrently with timeouts.",
|
| 193 |
-
tools=[Tool("live_quotes", live_quotes, "Fetch live quotes")],
|
| 194 |
-
),
|
| 195 |
-
"analyst": Agent(
|
| 196 |
-
role="Quant Analyst",
|
| 197 |
-
goal="Rank the universe by price, volume, return and MC score.",
|
| 198 |
-
backstory="Cache-first analytics identical to dashboard v4.5.",
|
| 199 |
-
tools=[
|
| 200 |
-
Tool("price_change_analysis", price_change_analysis),
|
| 201 |
-
Tool("volume_change_analysis", volume_change_analysis),
|
| 202 |
-
Tool("return_analysis", return_analysis),
|
| 203 |
-
Tool("top_score_analysis", top_score_analysis),
|
| 204 |
-
],
|
| 205 |
-
),
|
| 206 |
-
"portfolio_manager": Agent(
|
| 207 |
-
role="Portfolio Manager",
|
| 208 |
-
goal="Import broker holdings and value the book.",
|
| 209 |
-
backstory="Maps ICICI short names to NSE symbols.",
|
| 210 |
-
tools=[Tool("portfolio_import", portfolio_import)],
|
| 211 |
-
),
|
| 212 |
-
"recommender": Agent(
|
| 213 |
-
role="Recommender",
|
| 214 |
-
goal="Surface LSTM buy/sell calls when models exist.",
|
| 215 |
-
backstory="Bridges to the optional recommender module.",
|
| 216 |
-
tools=[
|
| 217 |
-
Tool("recommend", recommend),
|
| 218 |
-
Tool("train_models", train_models),
|
| 219 |
-
],
|
| 220 |
-
),
|
| 221 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/crew/crews.py
DELETED
|
@@ -1,128 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.crew.crews
|
| 3 |
-
==================
|
| 4 |
-
Pre-wired crews the dashboard service kicks off for its operation
|
| 5 |
-
categories. Each crew is a deterministic pipeline over the agents in
|
| 6 |
-
``nsedash.crew.agents`` — no LLM involved.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
from .framework import Agent, Crew, Process, Task
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class CrewFactory:
|
| 15 |
-
"""Builds crews bound to the shared agent roster."""
|
| 16 |
-
|
| 17 |
-
def __init__(self, agents: dict[str, Agent]):
|
| 18 |
-
self.agents = agents
|
| 19 |
-
|
| 20 |
-
def live_refresh_crew(self, symbols: list[str], preferred_source: str) -> Crew:
|
| 21 |
-
"""One live polling tick: fetch quotes for the displayed symbols."""
|
| 22 |
-
watcher = self.agents["market_watcher"]
|
| 23 |
-
return Crew(
|
| 24 |
-
agents=[watcher],
|
| 25 |
-
tasks=[Task(
|
| 26 |
-
description="Fetch live quotes for displayed symbols",
|
| 27 |
-
agent=watcher, tool="live_quotes", output_key="live",
|
| 28 |
-
inputs={"symbols": symbols, "preferred_source": preferred_source},
|
| 29 |
-
)],
|
| 30 |
-
name="live_refresh",
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
def price_analysis_crew(self, duration: str, change_type: str,
|
| 34 |
-
threshold: float, selected_date) -> Crew:
|
| 35 |
-
analyst = self.agents["analyst"]
|
| 36 |
-
return Crew(
|
| 37 |
-
agents=[analyst],
|
| 38 |
-
tasks=[Task(
|
| 39 |
-
description=f"{duration} {change_type} price-change screen",
|
| 40 |
-
agent=analyst, tool="price_change_analysis",
|
| 41 |
-
output_key="analysis",
|
| 42 |
-
inputs={"duration": duration, "change_type": change_type,
|
| 43 |
-
"threshold": threshold, "selected_date": selected_date},
|
| 44 |
-
)],
|
| 45 |
-
name="price_analysis",
|
| 46 |
-
)
|
| 47 |
-
|
| 48 |
-
def volume_analysis_crew(self, duration: str, volume_cutoff: float) -> Crew:
|
| 49 |
-
analyst = self.agents["analyst"]
|
| 50 |
-
return Crew(
|
| 51 |
-
agents=[analyst],
|
| 52 |
-
tasks=[Task(
|
| 53 |
-
description=f"{duration} max volume screen",
|
| 54 |
-
agent=analyst, tool="volume_change_analysis",
|
| 55 |
-
output_key="analysis",
|
| 56 |
-
inputs={"duration": duration, "volume_cutoff": volume_cutoff},
|
| 57 |
-
)],
|
| 58 |
-
name="volume_analysis",
|
| 59 |
-
)
|
| 60 |
-
|
| 61 |
-
def return_analysis_crew(self, duration: str, selected_date) -> Crew:
|
| 62 |
-
analyst = self.agents["analyst"]
|
| 63 |
-
return Crew(
|
| 64 |
-
agents=[analyst],
|
| 65 |
-
tasks=[Task(
|
| 66 |
-
description=f"{duration} max return simulation",
|
| 67 |
-
agent=analyst, tool="return_analysis", output_key="analysis",
|
| 68 |
-
inputs={"duration": duration, "selected_date": selected_date},
|
| 69 |
-
)],
|
| 70 |
-
name="return_analysis",
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
def top_score_crew(self) -> Crew:
|
| 74 |
-
analyst = self.agents["analyst"]
|
| 75 |
-
return Crew(
|
| 76 |
-
agents=[analyst],
|
| 77 |
-
tasks=[Task(
|
| 78 |
-
description="Top-50 Moneycontrol score screen",
|
| 79 |
-
agent=analyst, tool="top_score_analysis", output_key="analysis",
|
| 80 |
-
)],
|
| 81 |
-
name="top_score",
|
| 82 |
-
)
|
| 83 |
-
|
| 84 |
-
def refresh_crew(self, selected_date) -> Crew:
|
| 85 |
-
"""Full dashboard refresh: reload CSV then re-enrich the date."""
|
| 86 |
-
steward = self.agents["data_steward"]
|
| 87 |
-
return Crew(
|
| 88 |
-
agents=[steward],
|
| 89 |
-
tasks=[
|
| 90 |
-
Task(description="Reload historical CSV",
|
| 91 |
-
agent=steward, tool="reload_csv", output_key="reload"),
|
| 92 |
-
Task(description="Re-enrich the selected date",
|
| 93 |
-
agent=steward, tool="enrich_date", output_key="date_data",
|
| 94 |
-
inputs={"date": selected_date}),
|
| 95 |
-
],
|
| 96 |
-
process=Process.sequential,
|
| 97 |
-
name="dashboard_refresh",
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
-
def portfolio_crew(self, filepath: str | None = None) -> Crew:
|
| 101 |
-
pm = self.agents["portfolio_manager"]
|
| 102 |
-
return Crew(
|
| 103 |
-
agents=[pm],
|
| 104 |
-
tasks=[Task(description="Import portfolio",
|
| 105 |
-
agent=pm, tool="portfolio_import",
|
| 106 |
-
output_key="portfolio",
|
| 107 |
-
inputs={"filepath": filepath})],
|
| 108 |
-
name="portfolio_import",
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
def recommendation_crew(self, symbols: list[str]) -> Crew:
|
| 112 |
-
rec = self.agents["recommender"]
|
| 113 |
-
return Crew(
|
| 114 |
-
agents=[rec],
|
| 115 |
-
tasks=[Task(description="Buy/sell recommendations",
|
| 116 |
-
agent=rec, tool="recommend", output_key="recommendations",
|
| 117 |
-
inputs={"symbols": symbols})],
|
| 118 |
-
name="recommendation",
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
def training_crew(self) -> Crew:
|
| 122 |
-
rec = self.agents["recommender"]
|
| 123 |
-
return Crew(
|
| 124 |
-
agents=[rec],
|
| 125 |
-
tasks=[Task(description="Train all LSTM models",
|
| 126 |
-
agent=rec, tool="train_models", output_key="training")],
|
| 127 |
-
name="training",
|
| 128 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/crew/framework.py
DELETED
|
@@ -1,151 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.crew.framework
|
| 3 |
-
======================
|
| 4 |
-
A minimal crewAI-style agentic framework implemented in pure Python.
|
| 5 |
-
|
| 6 |
-
Deliberately NO LLM anywhere: agents are deterministic executors whose
|
| 7 |
-
"reasoning" is plain Python code. The surface mirrors crewAI's mental model
|
| 8 |
-
(Agent / Task / Crew / Process, ``kickoff()``) so orchestration reads the
|
| 9 |
-
same way, while every task resolves to a concrete Python callable (a Tool).
|
| 10 |
-
|
| 11 |
-
Concurrency: crews execute on an asyncio event loop. Blocking tool
|
| 12 |
-
functions are dispatched to a thread-pool executor, so Process.parallel
|
| 13 |
-
crews run their tasks genuinely concurrently and never block the caller's
|
| 14 |
-
event loop (used by the web layer's background job system).
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
from __future__ import annotations
|
| 18 |
-
|
| 19 |
-
import asyncio
|
| 20 |
-
import logging
|
| 21 |
-
import time
|
| 22 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 23 |
-
from dataclasses import dataclass, field
|
| 24 |
-
from enum import Enum
|
| 25 |
-
from typing import Any, Callable
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class Process(str, Enum):
|
| 29 |
-
sequential = "sequential"
|
| 30 |
-
parallel = "parallel"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
@dataclass
|
| 34 |
-
class Tool:
|
| 35 |
-
"""A named deterministic capability: a plain Python callable."""
|
| 36 |
-
name: str
|
| 37 |
-
func: Callable[..., Any]
|
| 38 |
-
description: str = ""
|
| 39 |
-
|
| 40 |
-
def run(self, *args, **kwargs) -> Any:
|
| 41 |
-
return self.func(*args, **kwargs)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
@dataclass
|
| 45 |
-
class Agent:
|
| 46 |
-
"""
|
| 47 |
-
A deterministic worker with a role, a goal, and a toolbox.
|
| 48 |
-
``execute(tool_name, ...)`` dispatches to the named tool. There is no
|
| 49 |
-
model call and no free-form planning — dispatch is exact-match.
|
| 50 |
-
"""
|
| 51 |
-
role: str
|
| 52 |
-
goal: str
|
| 53 |
-
backstory: str = ""
|
| 54 |
-
tools: list[Tool] = field(default_factory=list)
|
| 55 |
-
|
| 56 |
-
def tool(self, name: str) -> Tool:
|
| 57 |
-
for t in self.tools:
|
| 58 |
-
if t.name == name:
|
| 59 |
-
return t
|
| 60 |
-
raise KeyError(f"Agent '{self.role}' has no tool named '{name}'")
|
| 61 |
-
|
| 62 |
-
def execute(self, tool_name: str, /, *args, **kwargs) -> Any:
|
| 63 |
-
start = time.time()
|
| 64 |
-
try:
|
| 65 |
-
result = self.tool(tool_name).run(*args, **kwargs)
|
| 66 |
-
return result
|
| 67 |
-
finally:
|
| 68 |
-
logging.info(
|
| 69 |
-
f"AGENT[{self.role}] tool={tool_name} "
|
| 70 |
-
f"elapsed={time.time() - start:.2f}s"
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
@dataclass
|
| 75 |
-
class Task:
|
| 76 |
-
"""
|
| 77 |
-
A unit of work bound to an agent. ``tool`` names the agent tool to run;
|
| 78 |
-
``inputs`` may be a dict, or a callable(context) -> dict evaluated at
|
| 79 |
-
execution time so later tasks can consume earlier task outputs.
|
| 80 |
-
``output_key`` names the result in the crew context.
|
| 81 |
-
"""
|
| 82 |
-
description: str
|
| 83 |
-
agent: Agent
|
| 84 |
-
tool: str
|
| 85 |
-
output_key: str
|
| 86 |
-
inputs: dict | Callable[[dict], dict] | None = None
|
| 87 |
-
|
| 88 |
-
def resolve_inputs(self, context: dict) -> dict:
|
| 89 |
-
if self.inputs is None:
|
| 90 |
-
return {}
|
| 91 |
-
if callable(self.inputs):
|
| 92 |
-
return self.inputs(context)
|
| 93 |
-
return dict(self.inputs)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
class Crew:
|
| 97 |
-
"""
|
| 98 |
-
Orchestrates tasks across agents.
|
| 99 |
-
|
| 100 |
-
Process.sequential — tasks run in declared order; each task sees prior
|
| 101 |
-
outputs in the shared context.
|
| 102 |
-
Process.parallel — tasks run concurrently; each task sees only the
|
| 103 |
-
initial inputs.
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
_executor = ThreadPoolExecutor(max_workers=16, thread_name_prefix="crew")
|
| 107 |
-
|
| 108 |
-
def __init__(self, agents: list[Agent], tasks: list[Task],
|
| 109 |
-
process: Process = Process.sequential, name: str = "crew"):
|
| 110 |
-
self.agents = agents
|
| 111 |
-
self.tasks = tasks
|
| 112 |
-
self.process = process
|
| 113 |
-
self.name = name
|
| 114 |
-
|
| 115 |
-
async def _run_task(self, task: Task, context: dict) -> Any:
|
| 116 |
-
loop = asyncio.get_running_loop()
|
| 117 |
-
kwargs = task.resolve_inputs(context)
|
| 118 |
-
return await loop.run_in_executor(
|
| 119 |
-
self._executor,
|
| 120 |
-
lambda: task.agent.execute(task.tool, **kwargs),
|
| 121 |
-
)
|
| 122 |
-
|
| 123 |
-
async def kickoff_async(self, inputs: dict | None = None) -> dict:
|
| 124 |
-
context: dict = dict(inputs or {})
|
| 125 |
-
logging.info(f"CREW[{self.name}] kickoff process={self.process.value} "
|
| 126 |
-
f"tasks={len(self.tasks)}")
|
| 127 |
-
if self.process is Process.sequential:
|
| 128 |
-
for task in self.tasks:
|
| 129 |
-
context[task.output_key] = await self._run_task(task, context)
|
| 130 |
-
else:
|
| 131 |
-
results = await asyncio.gather(
|
| 132 |
-
*(self._run_task(t, context) for t in self.tasks)
|
| 133 |
-
)
|
| 134 |
-
for task, result in zip(self.tasks, results):
|
| 135 |
-
context[task.output_key] = result
|
| 136 |
-
return context
|
| 137 |
-
|
| 138 |
-
def kickoff(self, inputs: dict | None = None) -> dict:
|
| 139 |
-
"""
|
| 140 |
-
Synchronous entry point. Runs on a private event loop when no loop
|
| 141 |
-
is active in the current thread; otherwise callers should await
|
| 142 |
-
``kickoff_async`` directly.
|
| 143 |
-
"""
|
| 144 |
-
try:
|
| 145 |
-
asyncio.get_running_loop()
|
| 146 |
-
except RuntimeError:
|
| 147 |
-
return asyncio.run(self.kickoff_async(inputs))
|
| 148 |
-
raise RuntimeError(
|
| 149 |
-
"Crew.kickoff() called from a running event loop; "
|
| 150 |
-
"use `await crew.kickoff_async(...)` instead."
|
| 151 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/logging_utils.py
DELETED
|
@@ -1,82 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.logging_utils
|
| 3 |
-
=====================
|
| 4 |
-
Application + metrics logging, and the elapsed-time decorator.
|
| 5 |
-
|
| 6 |
-
The Tkinter ``wait_dialog_decorator`` from v4.5 has no equivalent in a
|
| 7 |
-
server process; its UX role (a modal "please wait" indicator) is fulfilled
|
| 8 |
-
by the web job system (``nsedash.web.background``) which the frontend polls
|
| 9 |
-
to show a spinner. ``elapsed_time_logger`` is retained with identical log
|
| 10 |
-
format: ``timestamp | function | message | N.NN sec``.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
import logging
|
| 16 |
-
import threading
|
| 17 |
-
import time
|
| 18 |
-
from datetime import datetime
|
| 19 |
-
from functools import wraps
|
| 20 |
-
from pathlib import Path
|
| 21 |
-
|
| 22 |
-
_metrics_lock = threading.Lock()
|
| 23 |
-
_metrics_file: Path | None = None
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def setup_logging(log_file: Path, metrics_file: Path) -> None:
|
| 27 |
-
"""Configure the root logger to write to the dashboard log file."""
|
| 28 |
-
global _metrics_file
|
| 29 |
-
_metrics_file = metrics_file
|
| 30 |
-
log_file.parent.mkdir(parents=True, exist_ok=True)
|
| 31 |
-
root = logging.getLogger()
|
| 32 |
-
root.setLevel(logging.INFO)
|
| 33 |
-
# Avoid duplicate handlers on repeated app-factory calls (tests).
|
| 34 |
-
if not any(
|
| 35 |
-
isinstance(h, logging.FileHandler)
|
| 36 |
-
and getattr(h, 'baseFilename', None) == str(log_file)
|
| 37 |
-
for h in root.handlers
|
| 38 |
-
):
|
| 39 |
-
handler = logging.FileHandler(str(log_file))
|
| 40 |
-
handler.setFormatter(
|
| 41 |
-
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
| 42 |
-
)
|
| 43 |
-
root.addHandler(handler)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def log_operation(operation_name: str, elapsed_time: float) -> None:
|
| 47 |
-
"""Append an operation timing entry to the metrics log (thread-safe)."""
|
| 48 |
-
if _metrics_file is None:
|
| 49 |
-
return
|
| 50 |
-
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 51 |
-
line = f"{timestamp} | {operation_name} | {elapsed_time:.2f} sec\n"
|
| 52 |
-
# AUDIT FIX M-10: original opened the file unsynchronised from multiple
|
| 53 |
-
# threads; interleaved writes could corrupt lines.
|
| 54 |
-
with _metrics_lock:
|
| 55 |
-
with open(_metrics_file, "a") as fh:
|
| 56 |
-
fh.write(line)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def log_dashboard_message(message: str) -> None:
|
| 60 |
-
"""Write a progress message to the dashboard log."""
|
| 61 |
-
logging.info(f"DASHBOARD_PROGRESS: {message}")
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def elapsed_time_logger(message: str = "Processing..."):
|
| 65 |
-
"""Decorator: logs wall-clock execution time of the wrapped callable."""
|
| 66 |
-
def decorator(func):
|
| 67 |
-
@wraps(func)
|
| 68 |
-
def wrapper(*args, **kwargs):
|
| 69 |
-
start_time = time.time()
|
| 70 |
-
function_name = func.__name__
|
| 71 |
-
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 72 |
-
try:
|
| 73 |
-
result = func(*args, **kwargs)
|
| 74 |
-
finally:
|
| 75 |
-
elapsed = time.time() - start_time
|
| 76 |
-
logging.info(
|
| 77 |
-
f"{timestamp} | {function_name} | {message} | {elapsed:.2f} sec"
|
| 78 |
-
)
|
| 79 |
-
log_operation(function_name, elapsed)
|
| 80 |
-
return result
|
| 81 |
-
return wrapper
|
| 82 |
-
return decorator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/__init__.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.web
|
| 3 |
-
===========
|
| 4 |
-
Flask application factory. Wires the service layer, agent roster, crews,
|
| 5 |
-
async runtime, job manager, live scheduler and script runner into
|
| 6 |
-
``app.extensions["nsedash"]``.
|
| 7 |
-
|
| 8 |
-
HF Persistence
|
| 9 |
-
--------------
|
| 10 |
-
When ``SPACE_ID`` is set (HuggingFace Space), ``HFDataSync`` is
|
| 11 |
-
activated:
|
| 12 |
-
- On startup: pulls all processed files from ``Sam-max1/nsedash_data``
|
| 13 |
-
*before* ``DashboardService`` is created so the service sees fresh data.
|
| 14 |
-
- On every file write: pushes the changed file asynchronously (3-second
|
| 15 |
-
debounce, fire-and-forget background thread).
|
| 16 |
-
- On session cleanup: removes per-user files from the dataset.
|
| 17 |
-
|
| 18 |
-
Multi-User Auth
|
| 19 |
-
---------------
|
| 20 |
-
``user_auth.init(base_dir)`` is called once to set file paths, then the
|
| 21 |
-
``UserDatabase`` is loaded (creating the seed users.db if absent).
|
| 22 |
-
"""
|
| 23 |
-
|
| 24 |
-
from __future__ import annotations
|
| 25 |
-
|
| 26 |
-
import os
|
| 27 |
-
|
| 28 |
-
from flask import Flask
|
| 29 |
-
|
| 30 |
-
from ..config import HF_DATASET_REPO, Paths
|
| 31 |
-
from ..core.dashboard_service import DashboardService
|
| 32 |
-
from ..core.hf_sync import make_sync
|
| 33 |
-
from ..core import user_auth
|
| 34 |
-
from ..crew.agents import build_agents
|
| 35 |
-
from ..crew.crews import CrewFactory
|
| 36 |
-
from ..logging_utils import setup_logging
|
| 37 |
-
from .api import api
|
| 38 |
-
from .background import (AsyncRuntime, JobManager, LiveRefreshScheduler,
|
| 39 |
-
ScriptRunner)
|
| 40 |
-
from .routes import pages
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def create_app(base_dir=None) -> Flask:
|
| 44 |
-
paths = Paths(base_dir)
|
| 45 |
-
paths.ensure_dirs()
|
| 46 |
-
setup_logging(paths.log_file, paths.metrics_file)
|
| 47 |
-
|
| 48 |
-
# ------------------------------------------------------------------
|
| 49 |
-
# 1. HF Dataset sync — startup pull BEFORE DashboardService / auth init
|
| 50 |
-
# ------------------------------------------------------------------
|
| 51 |
-
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HF_PRIVATE_TOKEN")
|
| 52 |
-
hf_sync = make_sync(data_dir=paths.data_dir, repo_id=HF_DATASET_REPO, token=hf_token)
|
| 53 |
-
hf_sync.pull_on_startup()
|
| 54 |
-
|
| 55 |
-
# ------------------------------------------------------------------
|
| 56 |
-
# 2. Initialise user auth (file paths, seed users.db, link hf_sync)
|
| 57 |
-
# ------------------------------------------------------------------
|
| 58 |
-
user_auth.init(paths.base_dir, hf_sync=hf_sync)
|
| 59 |
-
user_auth.invalidate_all_sessions_on_startup()
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# ------------------------------------------------------------------
|
| 63 |
-
# 3. Service / crew layer
|
| 64 |
-
# ------------------------------------------------------------------
|
| 65 |
-
service = DashboardService(paths, hf_sync=hf_sync)
|
| 66 |
-
agents = build_agents(service.stock_data, service.watchlists, paths)
|
| 67 |
-
crews = CrewFactory(agents)
|
| 68 |
-
runtime = AsyncRuntime()
|
| 69 |
-
jobs = JobManager(runtime)
|
| 70 |
-
scheduler = LiveRefreshScheduler(runtime, service.load_live_data, base_dir=paths.base_dir)
|
| 71 |
-
scripts = ScriptRunner()
|
| 72 |
-
|
| 73 |
-
app = Flask(__name__)
|
| 74 |
-
app.extensions["nsedash"] = {
|
| 75 |
-
"paths": paths,
|
| 76 |
-
"service": service,
|
| 77 |
-
"services": {"admin": service},
|
| 78 |
-
"base_dir": paths.base_dir,
|
| 79 |
-
"agents": agents,
|
| 80 |
-
"crews": crews,
|
| 81 |
-
"runtime": runtime,
|
| 82 |
-
"jobs": jobs,
|
| 83 |
-
"scheduler": scheduler,
|
| 84 |
-
"scripts": scripts,
|
| 85 |
-
"hf_sync": hf_sync,
|
| 86 |
-
}
|
| 87 |
-
app.register_blueprint(pages)
|
| 88 |
-
app.register_blueprint(api)
|
| 89 |
-
|
| 90 |
-
# Start inactivity cleanup daemon thread and stats reporting thread
|
| 91 |
-
from .api import start_cleanup_thread, start_stats_thread
|
| 92 |
-
start_cleanup_thread(app)
|
| 93 |
-
start_stats_thread(app)
|
| 94 |
-
|
| 95 |
-
@app.after_request
|
| 96 |
-
def add_security_headers(response):
|
| 97 |
-
response.headers["Content-Security-Policy"] = (
|
| 98 |
-
"default-src 'self'; "
|
| 99 |
-
"script-src 'self' https://cdnjs.cloudflare.com; "
|
| 100 |
-
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
| 101 |
-
"font-src 'self' https://fonts.gstatic.com; "
|
| 102 |
-
"img-src 'self' data:; "
|
| 103 |
-
"connect-src 'self';"
|
| 104 |
-
)
|
| 105 |
-
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
| 106 |
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 107 |
-
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 108 |
-
response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()"
|
| 109 |
-
return response
|
| 110 |
-
|
| 111 |
-
return app
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/api.py
DELETED
|
@@ -1,1196 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.web.api
|
| 3 |
-
===============
|
| 4 |
-
REST API. Every v4.5 dashboard action maps to an endpoint. Long-running
|
| 5 |
-
operations (analysis crews, refresh, portfolio import, recommendations,
|
| 6 |
-
training) are submitted to the JobManager and polled by the frontend —
|
| 7 |
-
the web equivalent of the v4.5 wait-dialog + worker-thread pattern.
|
| 8 |
-
|
| 9 |
-
Authentication
|
| 10 |
-
--------------
|
| 11 |
-
All endpoints (except /api/auth/* and /api/status) require a valid
|
| 12 |
-
``X-Session-Token`` header issued by the login + TOTP flow.
|
| 13 |
-
The token resolves to a ``userid`` via ``user_auth.SessionManager``,
|
| 14 |
-
which is then used as the per-user isolation key in ``_svc()``.
|
| 15 |
-
"""
|
| 16 |
-
|
| 17 |
-
from __future__ import annotations
|
| 18 |
-
|
| 19 |
-
import logging
|
| 20 |
-
import os
|
| 21 |
-
import signal
|
| 22 |
-
import subprocess
|
| 23 |
-
import threading
|
| 24 |
-
import time
|
| 25 |
-
from pathlib import Path
|
| 26 |
-
|
| 27 |
-
import pandas as pd
|
| 28 |
-
from flask import Blueprint, current_app, jsonify, request, send_file
|
| 29 |
-
from werkzeug.utils import secure_filename
|
| 30 |
-
|
| 31 |
-
from ..config import UTILITY_SCRIPTS, WATCHLIST_SLOTS
|
| 32 |
-
from ..core import user_auth
|
| 33 |
-
|
| 34 |
-
api = Blueprint("api", __name__, url_prefix="/api")
|
| 35 |
-
|
| 36 |
-
# In-memory activity tracker (userid -> last_active timestamp)
|
| 37 |
-
_active_sessions: dict[str, float] = {}
|
| 38 |
-
SESSION_TIMEOUT_SECONDS = 15 * 60 # 15 minutes
|
| 39 |
-
|
| 40 |
-
# In-memory tracker for recently inactivated sessions to notify UI
|
| 41 |
-
_inactive_tokens: dict[str, float] = {}
|
| 42 |
-
|
| 43 |
-
# Hourly session stats
|
| 44 |
-
total_connections_this_hour = 0
|
| 45 |
-
total_disconnections_this_hour = 0
|
| 46 |
-
|
| 47 |
-
# Routes exempt from auth check
|
| 48 |
-
_AUTH_EXEMPT = {
|
| 49 |
-
"/api/auth/login",
|
| 50 |
-
"/api/auth/totp",
|
| 51 |
-
"/api/auth/logout",
|
| 52 |
-
"/api/auth/status",
|
| 53 |
-
"/api/status",
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
@api.before_request
|
| 58 |
-
def check_auth():
|
| 59 |
-
if current_app.testing:
|
| 60 |
-
return
|
| 61 |
-
if request.path in _AUTH_EXEMPT:
|
| 62 |
-
return
|
| 63 |
-
token = (request.headers.get("X-Session-Token")
|
| 64 |
-
or request.cookies.get("session_token")
|
| 65 |
-
or request.args.get("session_token"))
|
| 66 |
-
if not token:
|
| 67 |
-
return jsonify({"error": "Authentication required", "auth_required": True}), 401
|
| 68 |
-
valid, _userid = user_auth.SessionManager.validate_session(token)
|
| 69 |
-
if not valid:
|
| 70 |
-
if token:
|
| 71 |
-
now = time.time()
|
| 72 |
-
# Prune old entries to prevent leaks
|
| 73 |
-
for k in list(_inactive_tokens.keys()):
|
| 74 |
-
if now - _inactive_tokens[k] > 3600:
|
| 75 |
-
_inactive_tokens.pop(k, None)
|
| 76 |
-
if token in _inactive_tokens:
|
| 77 |
-
_inactive_tokens.pop(token, None)
|
| 78 |
-
return jsonify({"error": "Session terminated due to inactivity", "auth_required": True, "reason": "inactivity"}), 401
|
| 79 |
-
return jsonify({"error": "Session expired or invalid", "auth_required": True}), 401
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
# ---------------------------------------------------------------------------
|
| 83 |
-
# Auth endpoints
|
| 84 |
-
# ---------------------------------------------------------------------------
|
| 85 |
-
|
| 86 |
-
@api.post("/auth/login")
|
| 87 |
-
def auth_login():
|
| 88 |
-
"""Step 1: userid + password -> returns totp_required and optional QR."""
|
| 89 |
-
body = request.get_json(force=True)
|
| 90 |
-
userid = (body.get("userid") or "").strip()
|
| 91 |
-
password = body.get("password") or ""
|
| 92 |
-
if not userid or not password:
|
| 93 |
-
return jsonify({"ok": False, "message": "userid and password are required."}), 400
|
| 94 |
-
|
| 95 |
-
success, message, totp_required = user_auth.AuthenticationService.login(userid, password)
|
| 96 |
-
if not success:
|
| 97 |
-
return jsonify({"ok": False, "message": message}), 401
|
| 98 |
-
|
| 99 |
-
resp = {"ok": True, "userid": userid, "totp_required": totp_required, "message": message}
|
| 100 |
-
if message in ("first_login", "totp_setup"):
|
| 101 |
-
resp["qr"] = user_auth.AuthenticationService.get_totp_qr(userid)
|
| 102 |
-
user = user_auth.UserDatabase.get_user(userid) or {}
|
| 103 |
-
secret = user.get("totp_secret")
|
| 104 |
-
if not secret and user.get("totp_secret_encrypted"):
|
| 105 |
-
secret = user_auth.EncryptionManager.decrypt(user["totp_secret_encrypted"])
|
| 106 |
-
resp["secret"] = secret
|
| 107 |
-
return jsonify(resp)
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@api.post("/auth/totp")
|
| 111 |
-
def auth_totp():
|
| 112 |
-
"""Step 2: userid + TOTP code -> issues a session token."""
|
| 113 |
-
body = request.get_json(force=True)
|
| 114 |
-
userid = (body.get("userid") or "").strip()
|
| 115 |
-
code = (body.get("code") or "").strip()
|
| 116 |
-
if not userid or not code:
|
| 117 |
-
return jsonify({"ok": False, "message": "userid and code are required."}), 400
|
| 118 |
-
|
| 119 |
-
success, result = user_auth.AuthenticationService.verify_totp(userid, code)
|
| 120 |
-
if not success:
|
| 121 |
-
return jsonify({"ok": False, "message": result}), 401
|
| 122 |
-
|
| 123 |
-
user = user_auth.UserDatabase.get_user(userid) or {}
|
| 124 |
-
force_pw = bool(user.get("force_password_change", False))
|
| 125 |
-
|
| 126 |
-
_active_sessions[userid] = time.time()
|
| 127 |
-
global total_connections_this_hour
|
| 128 |
-
total_connections_this_hour += 1
|
| 129 |
-
print(f"[SESSION] Login: {userid} (IP: {request.remote_addr})", flush=True)
|
| 130 |
-
logging.info("Login: %s from %s", userid, request.remote_addr)
|
| 131 |
-
return jsonify({"ok": True, "session_token": result, "userid": userid, "force_password_change": force_pw})
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
@api.post("/auth/logout")
|
| 135 |
-
def auth_logout():
|
| 136 |
-
"""Destroy the current session."""
|
| 137 |
-
token = (request.headers.get("X-Session-Token")
|
| 138 |
-
or request.cookies.get("session_token"))
|
| 139 |
-
if token:
|
| 140 |
-
valid, userid = user_auth.SessionManager.validate_session(token)
|
| 141 |
-
user_auth.SessionManager.destroy_session(token)
|
| 142 |
-
if userid:
|
| 143 |
-
_active_sessions.pop(userid, None)
|
| 144 |
-
global total_disconnections_this_hour
|
| 145 |
-
total_disconnections_this_hour += 1
|
| 146 |
-
print(f"[SESSION] Logout: {userid}", flush=True)
|
| 147 |
-
return jsonify({"ok": True})
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
@api.get("/auth/status")
|
| 151 |
-
def auth_status():
|
| 152 |
-
"""Check if the current session is valid."""
|
| 153 |
-
token = (request.headers.get("X-Session-Token")
|
| 154 |
-
or request.cookies.get("session_token")
|
| 155 |
-
or request.args.get("session_token"))
|
| 156 |
-
if not token:
|
| 157 |
-
return jsonify({"authenticated": False})
|
| 158 |
-
valid, userid = user_auth.SessionManager.validate_session(token)
|
| 159 |
-
if valid:
|
| 160 |
-
u = user_auth.UserDatabase.get_user(userid) or {}
|
| 161 |
-
is_adm = True if userid == "admin" else bool(u.get("is_admin", False))
|
| 162 |
-
force_pw = bool(u.get("force_password_change", False))
|
| 163 |
-
from ..core.nvidia_llm import get_nvidia_api_status
|
| 164 |
-
return jsonify({"authenticated": True, "userid": userid,
|
| 165 |
-
"is_admin": is_adm,
|
| 166 |
-
"force_password_change": force_pw,
|
| 167 |
-
"nvidia_status": get_nvidia_api_status()})
|
| 168 |
-
|
| 169 |
-
# Check if this token was terminated due to inactivity
|
| 170 |
-
if token:
|
| 171 |
-
now = time.time()
|
| 172 |
-
for k in list(_inactive_tokens.keys()):
|
| 173 |
-
if now - _inactive_tokens[k] > 3600:
|
| 174 |
-
_inactive_tokens.pop(k, None)
|
| 175 |
-
if token in _inactive_tokens:
|
| 176 |
-
_inactive_tokens.pop(token, None)
|
| 177 |
-
return jsonify({"authenticated": False, "reason": "inactivity"})
|
| 178 |
-
|
| 179 |
-
return jsonify({"authenticated": False})
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
@api.post("/auth/change-password")
|
| 183 |
-
def auth_change_password():
|
| 184 |
-
"""Change user password enforcing policy & 2-time entry confirmation."""
|
| 185 |
-
token = (request.headers.get("X-Session-Token")
|
| 186 |
-
or request.cookies.get("session_token")
|
| 187 |
-
or request.args.get("session_token"))
|
| 188 |
-
if not token:
|
| 189 |
-
return jsonify({"ok": False, "message": "Authentication required."}), 401
|
| 190 |
-
valid, userid = user_auth.SessionManager.validate_session(token)
|
| 191 |
-
if not valid or not userid:
|
| 192 |
-
return jsonify({"ok": False, "message": "Session invalid or expired."}), 401
|
| 193 |
-
|
| 194 |
-
body = request.get_json(force=True)
|
| 195 |
-
current_pw = body.get("current_password")
|
| 196 |
-
new_pw = body.get("new_password") or ""
|
| 197 |
-
confirm_pw = body.get("confirm_password") or ""
|
| 198 |
-
|
| 199 |
-
user = user_auth.UserDatabase.get_user(userid) or {}
|
| 200 |
-
is_forced = bool(user.get("force_password_change", False))
|
| 201 |
-
|
| 202 |
-
ok, msg = user_auth.change_user_password(
|
| 203 |
-
userid, current_pw, new_pw, confirm_pw, is_admin_override=is_forced
|
| 204 |
-
)
|
| 205 |
-
if not ok:
|
| 206 |
-
return jsonify({"ok": False, "message": msg}), 400
|
| 207 |
-
|
| 208 |
-
return jsonify({"ok": True, "message": msg})
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def start_cleanup_thread(app):
|
| 212 |
-
import threading
|
| 213 |
-
from ..core import user_auth as _ua
|
| 214 |
-
|
| 215 |
-
def _cleanup():
|
| 216 |
-
while True:
|
| 217 |
-
time.sleep(60)
|
| 218 |
-
try:
|
| 219 |
-
with app.app_context():
|
| 220 |
-
_ua.SessionManager.cleanup_expired()
|
| 221 |
-
|
| 222 |
-
now = time.time()
|
| 223 |
-
extensions = app.extensions["nsedash"]
|
| 224 |
-
services = extensions["services"]
|
| 225 |
-
base_dir = extensions["base_dir"]
|
| 226 |
-
hf_sync = extensions.get("hf_sync")
|
| 227 |
-
from ..config import Paths
|
| 228 |
-
import shutil
|
| 229 |
-
|
| 230 |
-
expired_users = [
|
| 231 |
-
userid for userid, last_active in list(_active_sessions.items())
|
| 232 |
-
if userid not in ("admin",) and (now - last_active) > SESSION_TIMEOUT_SECONDS
|
| 233 |
-
]
|
| 234 |
-
|
| 235 |
-
if expired_users:
|
| 236 |
-
logging.info("[Cleanup] Expiring %d inactive session(s).", len(expired_users))
|
| 237 |
-
|
| 238 |
-
for userid in expired_users:
|
| 239 |
-
logging.info("[Cleanup] Purging session: %s", userid)
|
| 240 |
-
print(f"[SESSION] Disconnected (Inactivity): {userid}", flush=True)
|
| 241 |
-
global total_disconnections_this_hour
|
| 242 |
-
total_disconnections_this_hour += 1
|
| 243 |
-
|
| 244 |
-
for tok in _ua.SessionManager.get_userid_sessions(userid):
|
| 245 |
-
_inactive_tokens[tok] = time.time()
|
| 246 |
-
_ua.SessionManager.destroy_session(tok)
|
| 247 |
-
|
| 248 |
-
if userid in services:
|
| 249 |
-
del services[userid]
|
| 250 |
-
_active_sessions.pop(userid, None)
|
| 251 |
-
|
| 252 |
-
paths = Paths(base_dir=base_dir, session_token=userid)
|
| 253 |
-
try:
|
| 254 |
-
for f in (paths.state_file, paths.config_file, paths.portfolio_file):
|
| 255 |
-
if f.exists():
|
| 256 |
-
f.unlink()
|
| 257 |
-
if paths.watchlist_dir.exists():
|
| 258 |
-
shutil.rmtree(paths.watchlist_dir)
|
| 259 |
-
except Exception as exc:
|
| 260 |
-
logging.error("[Cleanup] Error removing files for %s: %s", userid, exc)
|
| 261 |
-
|
| 262 |
-
if hf_sync:
|
| 263 |
-
hf_sync.delete_session_files(userid)
|
| 264 |
-
|
| 265 |
-
except Exception as ex:
|
| 266 |
-
logging.error("[Cleanup] Exception: %s", ex)
|
| 267 |
-
|
| 268 |
-
threading.Thread(target=_cleanup, daemon=True).start()
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
def start_stats_thread(app):
|
| 272 |
-
import threading
|
| 273 |
-
import time
|
| 274 |
-
|
| 275 |
-
def _stats():
|
| 276 |
-
last_15m = time.time()
|
| 277 |
-
last_hour = time.time()
|
| 278 |
-
while True:
|
| 279 |
-
time.sleep(10)
|
| 280 |
-
now = time.time()
|
| 281 |
-
if now - last_15m >= 900:
|
| 282 |
-
last_15m = now
|
| 283 |
-
active_count = len(_active_sessions)
|
| 284 |
-
print(f"[STATS] {time.strftime('%Y-%m-%d %H:%M:%S')} - Active sessions: {active_count}", flush=True)
|
| 285 |
-
logging.info("[STATS] Active sessions: %d", active_count)
|
| 286 |
-
if now - last_hour >= 3600:
|
| 287 |
-
last_hour = now
|
| 288 |
-
global total_connections_this_hour, total_disconnections_this_hour
|
| 289 |
-
print(f"[STATS] Hourly: Logins={total_connections_this_hour}, Logouts={total_disconnections_this_hour}", flush=True)
|
| 290 |
-
logging.info("[STATS] Hourly: Logins=%d, Logouts=%d", total_connections_this_hour, total_disconnections_this_hour)
|
| 291 |
-
total_connections_this_hour = 0
|
| 292 |
-
total_disconnections_this_hour = 0
|
| 293 |
-
|
| 294 |
-
threading.Thread(target=_stats, daemon=True).start()
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
def get_session_token() -> str:
|
| 298 |
-
"""
|
| 299 |
-
Resolve the current request's session token to a userid.
|
| 300 |
-
|
| 301 |
-
The userid is the isolation key for DashboardService lookup.
|
| 302 |
-
Falls back to 'admin' for unauthenticated requests.
|
| 303 |
-
"""
|
| 304 |
-
token = (request.headers.get("X-Session-Token")
|
| 305 |
-
or request.cookies.get("session_token")
|
| 306 |
-
or request.args.get("session_token"))
|
| 307 |
-
if token:
|
| 308 |
-
valid, userid = user_auth.SessionManager.validate_session(token)
|
| 309 |
-
if valid and userid:
|
| 310 |
-
_active_sessions[userid] = time.time()
|
| 311 |
-
return userid
|
| 312 |
-
_active_sessions["admin"] = time.time()
|
| 313 |
-
return "admin"
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
@api.after_request
|
| 317 |
-
def backup_user_data_on_change(response):
|
| 318 |
-
"""Automatically backs up user files encrypted to users.db on POST/PUT/DELETE."""
|
| 319 |
-
if request.method in ("POST", "PUT", "DELETE"):
|
| 320 |
-
# Ensure we don't back up auth or admin-action endpoints themselves
|
| 321 |
-
if not request.path.startswith(("/api/auth/", "/api/admin/")):
|
| 322 |
-
userid = get_session_token()
|
| 323 |
-
if userid and userid != "admin":
|
| 324 |
-
extensions = current_app.extensions["nsedash"]
|
| 325 |
-
base_dir = extensions["base_dir"]
|
| 326 |
-
user_auth.backup_session_to_db(userid, base_dir)
|
| 327 |
-
return response
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def _svc():
|
| 331 |
-
token = get_session_token()
|
| 332 |
-
_active_sessions[token] = time.time() # Record last active timestamp
|
| 333 |
-
extensions = current_app.extensions["nsedash"]
|
| 334 |
-
services = extensions["services"]
|
| 335 |
-
if token not in services:
|
| 336 |
-
import shutil
|
| 337 |
-
from ..config import Paths
|
| 338 |
-
from ..core.dashboard_service import DashboardService
|
| 339 |
-
|
| 340 |
-
base_dir = extensions["base_dir"]
|
| 341 |
-
paths = Paths(base_dir=base_dir, session_token=token)
|
| 342 |
-
paths.ensure_dirs()
|
| 343 |
-
|
| 344 |
-
# Check if we can restore from encrypted users.db database
|
| 345 |
-
restored = user_auth.restore_session_from_db(token, base_dir)
|
| 346 |
-
|
| 347 |
-
if not restored:
|
| 348 |
-
# Seed default state file if missing
|
| 349 |
-
default_state_file = base_dir / 'processed' / 'nse_dashboard_contents.json'
|
| 350 |
-
if paths.state_file != default_state_file and not paths.state_file.exists():
|
| 351 |
-
if default_state_file.exists():
|
| 352 |
-
shutil.copy2(default_state_file, paths.state_file)
|
| 353 |
-
|
| 354 |
-
# Seed default config file if missing
|
| 355 |
-
default_config_file = base_dir / 'processed' / 'nse_dashboard_config.json'
|
| 356 |
-
if paths.config_file != default_config_file and not paths.config_file.exists():
|
| 357 |
-
if default_config_file.exists():
|
| 358 |
-
shutil.copy2(default_config_file, paths.config_file)
|
| 359 |
-
|
| 360 |
-
# Seed default watchlists if slot directory is empty or missing slots
|
| 361 |
-
default_wl_dir = base_dir / 'processed' / 'watchlists'
|
| 362 |
-
if paths.watchlist_dir != default_wl_dir and default_wl_dir.exists() and not any(paths.watchlist_dir.iterdir()):
|
| 363 |
-
import re
|
| 364 |
-
for item in default_wl_dir.iterdir():
|
| 365 |
-
if item.is_file():
|
| 366 |
-
match = re.match(r"nse_dashboard_watchlist(\d+)\.json", item.name)
|
| 367 |
-
if match:
|
| 368 |
-
num = int(match.group(1))
|
| 369 |
-
if 1 <= num <= WATCHLIST_SLOTS:
|
| 370 |
-
if default_wl_dir.joinpath(item.name).exists():
|
| 371 |
-
shutil.copy2(item, paths.watchlist_dir / item.name)
|
| 372 |
-
else:
|
| 373 |
-
# Create blank watchlist slot for other users
|
| 374 |
-
with open(paths.watchlist_dir / item.name, "w") as f:
|
| 375 |
-
json.dump({
|
| 376 |
-
"name": f"Watchlist {num}",
|
| 377 |
-
"symbols": [],
|
| 378 |
-
"last_updated": datetime.now().isoformat()
|
| 379 |
-
}, f, indent=2)
|
| 380 |
-
else:
|
| 381 |
-
# Copy non-slot files (like analysis cache)
|
| 382 |
-
shutil.copy2(item, paths.watchlist_dir / item.name)
|
| 383 |
-
|
| 384 |
-
# Ensure all 1..20 watchlist slots exist in paths.watchlist_dir
|
| 385 |
-
from datetime import datetime
|
| 386 |
-
import json
|
| 387 |
-
for num in range(1, WATCHLIST_SLOTS + 1):
|
| 388 |
-
slot_file = paths.watchlist_dir / f"nse_dashboard_watchlist{num}.json"
|
| 389 |
-
if not slot_file.exists():
|
| 390 |
-
src_file = default_wl_dir / f"nse_dashboard_watchlist{num}.json"
|
| 391 |
-
if paths.watchlist_dir != default_wl_dir and src_file.exists():
|
| 392 |
-
try:
|
| 393 |
-
shutil.copy2(src_file, slot_file)
|
| 394 |
-
except Exception:
|
| 395 |
-
pass
|
| 396 |
-
else:
|
| 397 |
-
try:
|
| 398 |
-
with open(slot_file, "w") as f:
|
| 399 |
-
json.dump({
|
| 400 |
-
"name": f"Watchlist {num}",
|
| 401 |
-
"symbols": [],
|
| 402 |
-
"last_updated": datetime.now().isoformat()
|
| 403 |
-
}, f, indent=2)
|
| 404 |
-
except Exception:
|
| 405 |
-
pass
|
| 406 |
-
|
| 407 |
-
services[token] = DashboardService(paths, hf_sync=extensions.get("hf_sync"))
|
| 408 |
-
|
| 409 |
-
return services[token]
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
def _crews():
|
| 414 |
-
return current_app.extensions["nsedash"]["crews"]
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
def _jobs():
|
| 418 |
-
return current_app.extensions["nsedash"]["jobs"]
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
def _scheduler():
|
| 422 |
-
return current_app.extensions["nsedash"]["scheduler"]
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
def _scripts():
|
| 426 |
-
return current_app.extensions["nsedash"]["scripts"]
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
# ---------------------------------------------------------------------------
|
| 430 |
-
# Board snapshot
|
| 431 |
-
# ---------------------------------------------------------------------------
|
| 432 |
-
|
| 433 |
-
@api.get("/board")
|
| 434 |
-
def board():
|
| 435 |
-
charts = request.args.get("charts", "1") != "0"
|
| 436 |
-
return jsonify(_svc().board_payload(include_charts=charts))
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
# ---------------------------------------------------------------------------
|
| 440 |
-
# Cell management
|
| 441 |
-
# ---------------------------------------------------------------------------
|
| 442 |
-
|
| 443 |
-
@api.post("/stocks")
|
| 444 |
-
def add_stock():
|
| 445 |
-
body = request.get_json(force=True)
|
| 446 |
-
key = body.get("key")
|
| 447 |
-
svc = _svc()
|
| 448 |
-
cell = svc.add_stock(key)
|
| 449 |
-
if cell is None:
|
| 450 |
-
return jsonify({"ok": False,
|
| 451 |
-
"message": f"Stock '{key}' not found in data."}), 404
|
| 452 |
-
return jsonify({"ok": True, "cell": svc._cell_payload(cell)})
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
@api.delete("/stocks/<symbol>")
|
| 456 |
-
def remove_stock(symbol):
|
| 457 |
-
ok = _svc().remove_stock(symbol)
|
| 458 |
-
return jsonify({"ok": ok})
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
@api.delete("/stocks")
|
| 462 |
-
def delete_all():
|
| 463 |
-
_svc().delete_all_stocks(mark_modified=True)
|
| 464 |
-
return jsonify({"ok": True})
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
@api.post("/stocks/<symbol>/inputs")
|
| 468 |
-
def cell_inputs(symbol):
|
| 469 |
-
body = request.get_json(force=True)
|
| 470 |
-
result = _svc().update_cell_inputs(
|
| 471 |
-
symbol, body.get("quantity", 0), body.get("price", 0)
|
| 472 |
-
)
|
| 473 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
@api.post("/stocks/<symbol>/select")
|
| 477 |
-
def cell_select(symbol):
|
| 478 |
-
body = request.get_json(force=True)
|
| 479 |
-
_svc().toggle_selection(symbol, bool(body.get("selected")))
|
| 480 |
-
return jsonify({"ok": True})
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
@api.post("/mc-link")
|
| 484 |
-
def set_mc_link():
|
| 485 |
-
body = request.get_json(force=True)
|
| 486 |
-
svc = _svc()
|
| 487 |
-
try:
|
| 488 |
-
entry = svc.stock_data.set_mc_link(
|
| 489 |
-
body.get("symbol", ""), body.get("security", ""),
|
| 490 |
-
body.get("link", "")
|
| 491 |
-
)
|
| 492 |
-
except ValueError as e:
|
| 493 |
-
return jsonify({"ok": False, "message": str(e)}), 400
|
| 494 |
-
svc._mc_links_cache = None # invalidate shared cache
|
| 495 |
-
return jsonify({"ok": True, "entry": entry,
|
| 496 |
-
"message": f"Moneycontrol link saved for "
|
| 497 |
-
f"{body.get('symbol')}."})
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
# ---------------------------------------------------------------------------
|
| 501 |
-
# Date / refresh / export / config
|
| 502 |
-
# ---------------------------------------------------------------------------
|
| 503 |
-
|
| 504 |
-
@api.post("/date")
|
| 505 |
-
def change_date():
|
| 506 |
-
body = request.get_json(force=True)
|
| 507 |
-
result = _svc().change_date(body.get("date", ""))
|
| 508 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
@api.post("/refresh")
|
| 512 |
-
def refresh():
|
| 513 |
-
svc, crews, jobs = _svc(), _crews(), _jobs()
|
| 514 |
-
if svc.real_time_active:
|
| 515 |
-
_scheduler().stop()
|
| 516 |
-
|
| 517 |
-
async def run():
|
| 518 |
-
crew = crews.refresh_crew(svc.selected_date)
|
| 519 |
-
result = svc.refresh_dashboard()
|
| 520 |
-
_ = crew # crew retained for parity; refresh_dashboard drives reload
|
| 521 |
-
return result
|
| 522 |
-
|
| 523 |
-
job_id = jobs.submit("Refreshing dashboard data...", run)
|
| 524 |
-
return jsonify({"ok": True, "job_id": job_id})
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
@api.post("/export")
|
| 528 |
-
def export_csv():
|
| 529 |
-
result = _svc().export_to_csv()
|
| 530 |
-
return jsonify(result), (200 if result.get("ok") else 500)
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
@api.get("/export/download")
|
| 534 |
-
def export_download():
|
| 535 |
-
path = request.args.get("file", "")
|
| 536 |
-
p = Path(path)
|
| 537 |
-
if not p.exists() or p.parent != _svc().paths.log_dir:
|
| 538 |
-
return jsonify({"ok": False, "message": "File not found."}), 404
|
| 539 |
-
return send_file(p, as_attachment=True)
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
@api.post("/config")
|
| 543 |
-
def set_config():
|
| 544 |
-
body = request.get_json(force=True)
|
| 545 |
-
svc = _svc()
|
| 546 |
-
if "volume_cutoff" in body:
|
| 547 |
-
try:
|
| 548 |
-
v = float(body["volume_cutoff"])
|
| 549 |
-
if v < 0:
|
| 550 |
-
raise ValueError
|
| 551 |
-
svc.volume_cutoff_threshold = v
|
| 552 |
-
except (TypeError, ValueError):
|
| 553 |
-
return jsonify({"ok": False,
|
| 554 |
-
"message": "Volume cutoff must be a "
|
| 555 |
-
"non-negative number."}), 400
|
| 556 |
-
svc.persist_config()
|
| 557 |
-
return jsonify({"ok": True,
|
| 558 |
-
"volume_cutoff": svc.volume_cutoff_threshold})
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
# ---------------------------------------------------------------------------
|
| 562 |
-
# Live data
|
| 563 |
-
# ---------------------------------------------------------------------------
|
| 564 |
-
|
| 565 |
-
@api.post("/live/start")
|
| 566 |
-
def live_start():
|
| 567 |
-
body = request.get_json(force=True)
|
| 568 |
-
svc = _svc()
|
| 569 |
-
result = svc.start_live(body.get("source"), body.get("refresh_rate"))
|
| 570 |
-
if not result.get("ok"):
|
| 571 |
-
return jsonify(result), 400
|
| 572 |
-
if not svc.is_trading_hour():
|
| 573 |
-
result["warning"] = ("Outside NSE trading hours (09:15–15:30 IST, "
|
| 574 |
-
"Mon–Fri). Prices may be stale.")
|
| 575 |
-
_scheduler().start(svc.live_refresh_rate)
|
| 576 |
-
return jsonify(result)
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
@api.post("/live/stop")
|
| 580 |
-
def live_stop():
|
| 581 |
-
_scheduler().stop()
|
| 582 |
-
return jsonify(_svc().stop_live())
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
@api.post("/live/refresh")
|
| 586 |
-
def live_refresh_now():
|
| 587 |
-
svc, jobs = _svc(), _jobs()
|
| 588 |
-
|
| 589 |
-
async def run():
|
| 590 |
-
import asyncio
|
| 591 |
-
loop = asyncio.get_running_loop()
|
| 592 |
-
return await loop.run_in_executor(None, svc.load_live_data)
|
| 593 |
-
|
| 594 |
-
job_id = jobs.submit("Fetching live data...", run)
|
| 595 |
-
return jsonify({"ok": True, "job_id": job_id})
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
# ---------------------------------------------------------------------------
|
| 599 |
-
# Watchlists
|
| 600 |
-
# ---------------------------------------------------------------------------
|
| 601 |
-
|
| 602 |
-
@api.get("/watchlists")
|
| 603 |
-
def watchlist_names():
|
| 604 |
-
return jsonify({"names": _svc().watchlist_names,
|
| 605 |
-
"slots": WATCHLIST_SLOTS})
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
@api.post("/watchlists/load/<int:num>")
|
| 609 |
-
def watchlist_load(num):
|
| 610 |
-
result = _svc().load_watchlist(num)
|
| 611 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
@api.post("/watchlists/load-name")
|
| 615 |
-
def watchlist_load_name():
|
| 616 |
-
body = request.get_json(force=True)
|
| 617 |
-
result = _svc().load_dashboard_state_by_name(body.get("name", ""))
|
| 618 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
@api.post("/watchlists/save")
|
| 622 |
-
def watchlist_save():
|
| 623 |
-
result = _svc().save_dashboard()
|
| 624 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
@api.post("/watchlists/save-as")
|
| 628 |
-
def watchlist_save_as():
|
| 629 |
-
body = request.get_json(force=True)
|
| 630 |
-
result = _svc().save_as_watchlist(body.get("slot"), body.get("name", ""))
|
| 631 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
@api.post("/watchlists/add-selected")
|
| 635 |
-
def watchlist_add_selected():
|
| 636 |
-
body = request.get_json(force=True)
|
| 637 |
-
result = _svc().add_selected_to_watchlist(body.get("slot"))
|
| 638 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
@api.post("/watchlists/load-file")
|
| 642 |
-
def watchlist_load_file():
|
| 643 |
-
"""Arbitrary watchlist JSON: uploaded file or a known live-analysis name."""
|
| 644 |
-
svc = _svc()
|
| 645 |
-
known = {
|
| 646 |
-
"maxvol50": svc.paths.max_vol_watchlist_file,
|
| 647 |
-
"maxprice50": svc.paths.max_price_watchlist_file,
|
| 648 |
-
"maxfall50": svc.paths.max_fall_watchlist_file,
|
| 649 |
-
"maxscore50": svc.paths.max_score_watchlist_file,
|
| 650 |
-
"maxreturn1w50": svc.paths.max_return_files.get("1W"),
|
| 651 |
-
"maxreturn1m50": svc.paths.max_return_files.get("1M"),
|
| 652 |
-
"maxreturn3m50": svc.paths.max_return_files.get("3M"),
|
| 653 |
-
}
|
| 654 |
-
name = request.form.get("known") or (request.json or {}).get("known") \
|
| 655 |
-
if not request.files else None
|
| 656 |
-
if name:
|
| 657 |
-
path = known.get(name)
|
| 658 |
-
if not path:
|
| 659 |
-
return jsonify({"ok": False, "message": "Unknown analysis file."}), 400
|
| 660 |
-
result = svc.load_watchlist_from_file(path)
|
| 661 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 662 |
-
f = request.files.get("file")
|
| 663 |
-
if not f:
|
| 664 |
-
return jsonify({"ok": False, "message": "No file supplied."}), 400
|
| 665 |
-
dest = svc.paths.watchlist_dir / secure_filename(f.filename)
|
| 666 |
-
f.save(dest)
|
| 667 |
-
result = svc.load_watchlist_from_file(dest)
|
| 668 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
@api.post("/index/<name>")
|
| 672 |
-
def load_index(name):
|
| 673 |
-
result = _svc().load_index_stocks(name.upper())
|
| 674 |
-
return jsonify(result), (200 if result.get("ok") else 400)
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
# ---------------------------------------------------------------------------
|
| 678 |
-
# Analysis (crew-driven, job-based)
|
| 679 |
-
# ---------------------------------------------------------------------------
|
| 680 |
-
|
| 681 |
-
def _analysis_job(description, crew_builder):
|
| 682 |
-
svc, jobs = _svc(), _jobs()
|
| 683 |
-
|
| 684 |
-
async def run():
|
| 685 |
-
ctx = await crew_builder().kickoff_async()
|
| 686 |
-
a = ctx["analysis"]
|
| 687 |
-
return svc.display_analysis_result(
|
| 688 |
-
a["symbols"], a["json_name"], a["cached"]
|
| 689 |
-
)
|
| 690 |
-
|
| 691 |
-
job_id = jobs.submit(description, run)
|
| 692 |
-
return jsonify({"ok": True, "job_id": job_id})
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
@api.post("/analysis/price")
|
| 696 |
-
def analysis_price():
|
| 697 |
-
body = request.get_json(force=True)
|
| 698 |
-
duration = body.get("duration")
|
| 699 |
-
change_type = body.get("change_type")
|
| 700 |
-
try:
|
| 701 |
-
threshold = float(body.get("threshold"))
|
| 702 |
-
except (TypeError, ValueError):
|
| 703 |
-
return jsonify({"ok": False,
|
| 704 |
-
"message": "Threshold must be a number."}), 400
|
| 705 |
-
svc, crews = _svc(), _crews()
|
| 706 |
-
return _analysis_job(
|
| 707 |
-
f"Calculating {duration} {change_type} price changes...",
|
| 708 |
-
lambda: crews.price_analysis_crew(
|
| 709 |
-
duration, change_type, threshold, svc.selected_date
|
| 710 |
-
),
|
| 711 |
-
)
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
@api.post("/analysis/volume")
|
| 715 |
-
def analysis_volume():
|
| 716 |
-
body = request.get_json(force=True)
|
| 717 |
-
duration = body.get("duration")
|
| 718 |
-
svc, crews = _svc(), _crews()
|
| 719 |
-
return _analysis_job(
|
| 720 |
-
f"Calculating {duration} volume movers...",
|
| 721 |
-
lambda: crews.volume_analysis_crew(
|
| 722 |
-
duration, svc.volume_cutoff_threshold
|
| 723 |
-
),
|
| 724 |
-
)
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
@api.post("/analysis/return")
|
| 728 |
-
def analysis_return():
|
| 729 |
-
body = request.get_json(force=True)
|
| 730 |
-
duration = body.get("duration")
|
| 731 |
-
svc, crews = _svc(), _crews()
|
| 732 |
-
return _analysis_job(
|
| 733 |
-
f"Calculating {duration} max returns...",
|
| 734 |
-
lambda: crews.return_analysis_crew(duration, svc.selected_date),
|
| 735 |
-
)
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
@api.post("/analysis/top-score")
|
| 739 |
-
def analysis_top_score():
|
| 740 |
-
crews = _crews()
|
| 741 |
-
return _analysis_job("Loading top Moneycontrol scores...",
|
| 742 |
-
crews.top_score_crew)
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
# ---------------------------------------------------------------------------
|
| 746 |
-
# Portfolio / recommender
|
| 747 |
-
# ---------------------------------------------------------------------------
|
| 748 |
-
|
| 749 |
-
@api.post("/portfolio/import")
|
| 750 |
-
def portfolio_import():
|
| 751 |
-
svc, crews, jobs = _svc(), _crews(), _jobs()
|
| 752 |
-
|
| 753 |
-
# Save the file from request context if uploaded
|
| 754 |
-
f = request.files.get("file")
|
| 755 |
-
filepath = None
|
| 756 |
-
if f:
|
| 757 |
-
session_token = get_session_token()
|
| 758 |
-
filename = secure_filename(f.filename)
|
| 759 |
-
dest = svc.paths.data_dir / f"uploaded_portfolio_{session_token}.csv"
|
| 760 |
-
f.save(dest)
|
| 761 |
-
filepath = str(dest)
|
| 762 |
-
else:
|
| 763 |
-
# Fallback to existing csv version of portfolio file if it exists
|
| 764 |
-
csv_path = Path(str(svc.paths.portfolio_file).replace('.parquet', '.csv'))
|
| 765 |
-
if csv_path.exists():
|
| 766 |
-
filepath = str(csv_path)
|
| 767 |
-
|
| 768 |
-
async def run():
|
| 769 |
-
ctx = await crews.portfolio_crew(filepath).kickoff_async()
|
| 770 |
-
p = ctx["portfolio"]
|
| 771 |
-
|
| 772 |
-
result = svc.load_portfolio_holdings(p["holdings"], p["unmapped"], p["broker_name"])
|
| 773 |
-
|
| 774 |
-
# Save the raw uploaded CSV to the parquet portfolio_file
|
| 775 |
-
if result.get("ok") and filepath:
|
| 776 |
-
try:
|
| 777 |
-
df_raw = pd.read_csv(filepath)
|
| 778 |
-
df_raw.to_parquet(svc.paths.portfolio_file, index=False)
|
| 779 |
-
except Exception as e:
|
| 780 |
-
logging.warning(f"Failed to save raw portfolio to parquet: {e}")
|
| 781 |
-
|
| 782 |
-
# Trigger session backup to database/sync
|
| 783 |
-
svc.watchlists._backup()
|
| 784 |
-
|
| 785 |
-
# Clean up the temporary uploaded CSV file
|
| 786 |
-
try:
|
| 787 |
-
Path(filepath).unlink(missing_ok=True)
|
| 788 |
-
except Exception as e:
|
| 789 |
-
logging.warning(f"Failed to delete temporary uploaded file {filepath}: {e}")
|
| 790 |
-
|
| 791 |
-
return result
|
| 792 |
-
|
| 793 |
-
job_id = jobs.submit("Importing portfolio...", run)
|
| 794 |
-
return jsonify({"ok": True, "job_id": job_id})
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
@api.post("/recommend")
|
| 798 |
-
def recommend():
|
| 799 |
-
svc, crews, jobs = _svc(), _crews(), _jobs()
|
| 800 |
-
symbols = [c.symbol for c in svc.cells]
|
| 801 |
-
if not symbols:
|
| 802 |
-
return jsonify({"ok": False,
|
| 803 |
-
"message": "No stocks on the board to analyse."}), 400
|
| 804 |
-
|
| 805 |
-
async def run():
|
| 806 |
-
ctx = await crews.recommendation_crew(symbols).kickoff_async()
|
| 807 |
-
recs = ctx["recommendations"]
|
| 808 |
-
by_symbol = {r["symbol"]: r for r in recs}
|
| 809 |
-
for cell in svc.cells:
|
| 810 |
-
cell.recommendation = by_symbol.get(cell.symbol)
|
| 811 |
-
return {"ok": True, "recommendations": recs}
|
| 812 |
-
|
| 813 |
-
job_id = jobs.submit("Running buy/sell recommender...", run)
|
| 814 |
-
return jsonify({"ok": True, "job_id": job_id})
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
@api.post("/train")
|
| 818 |
-
def train():
|
| 819 |
-
crews, jobs = _crews(), _jobs()
|
| 820 |
-
|
| 821 |
-
async def run():
|
| 822 |
-
ctx = await crews.training_crew().kickoff_async()
|
| 823 |
-
return {"ok": True, **ctx["training"]}
|
| 824 |
-
|
| 825 |
-
job_id = jobs.submit("Training LSTM models for all symbols...", run)
|
| 826 |
-
return jsonify({"ok": True, "job_id": job_id})
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
# ---------------------------------------------------------------------------
|
| 830 |
-
# Jobs
|
| 831 |
-
# ---------------------------------------------------------------------------
|
| 832 |
-
|
| 833 |
-
@api.get("/jobs/<job_id>")
|
| 834 |
-
def job_status(job_id):
|
| 835 |
-
job = _jobs().get(job_id)
|
| 836 |
-
if job is None:
|
| 837 |
-
return jsonify({"ok": False, "message": "Unknown job."}), 404
|
| 838 |
-
return jsonify(_jobs().to_payload(job))
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
# ---------------------------------------------------------------------------
|
| 842 |
-
# Utility scripts (interactive web terminal)
|
| 843 |
-
# ---------------------------------------------------------------------------
|
| 844 |
-
|
| 845 |
-
@api.get("/scripts")
|
| 846 |
-
def scripts_list():
|
| 847 |
-
return jsonify({"scripts": [
|
| 848 |
-
{"label": label, "file": file} for label, file in UTILITY_SCRIPTS
|
| 849 |
-
]})
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
@api.post("/scripts/start")
|
| 853 |
-
def scripts_start():
|
| 854 |
-
body = request.get_json(force=True)
|
| 855 |
-
file = body.get("file", "")
|
| 856 |
-
if file not in {f for _, f in UTILITY_SCRIPTS}:
|
| 857 |
-
return jsonify({"ok": False, "message": "Unknown script."}), 400
|
| 858 |
-
svc = _svc()
|
| 859 |
-
run = _scripts().start(file, str(svc.paths.base_dir))
|
| 860 |
-
logging.info(f"Started utility script {file} run={run.id}")
|
| 861 |
-
return jsonify({"ok": True, "run_id": run.id})
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
@api.get("/scripts/<run_id>/output")
|
| 865 |
-
def scripts_output(run_id):
|
| 866 |
-
run = _scripts().get(run_id)
|
| 867 |
-
if run is None:
|
| 868 |
-
return jsonify({"ok": False, "message": "Unknown run."}), 404
|
| 869 |
-
after = request.args.get("after", -1, type=int)
|
| 870 |
-
return jsonify({"ok": True, "lines": run.lines_since(after),
|
| 871 |
-
"finished": run.finished,
|
| 872 |
-
"returncode": run.returncode})
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
@api.post("/scripts/<run_id>/input")
|
| 876 |
-
def scripts_input(run_id):
|
| 877 |
-
run = _scripts().get(run_id)
|
| 878 |
-
if run is None:
|
| 879 |
-
return jsonify({"ok": False, "message": "Unknown run."}), 404
|
| 880 |
-
body = request.get_json(force=True)
|
| 881 |
-
ok = run.send_input(body.get("text", ""))
|
| 882 |
-
return jsonify({"ok": ok})
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
@api.get("/status")
|
| 886 |
-
def server_status():
|
| 887 |
-
svc = _svc()
|
| 888 |
-
exiting = getattr(svc, "is_exiting", False)
|
| 889 |
-
is_hf = "SPACE_ID" in os.environ
|
| 890 |
-
from ..core.nvidia_llm import get_nvidia_api_status
|
| 891 |
-
return jsonify({"ok": True, "exiting": exiting, "is_hf": is_hf,
|
| 892 |
-
"nvidia_status": get_nvidia_api_status()})
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
@api.post("/shutdown")
|
| 896 |
-
def shutdown():
|
| 897 |
-
is_hf = "SPACE_ID" in os.environ
|
| 898 |
-
if is_hf:
|
| 899 |
-
logging.info("Hugging Face mode: /shutdown cleans up current user session.")
|
| 900 |
-
userid = get_session_token()
|
| 901 |
-
extensions = current_app.extensions["nsedash"]
|
| 902 |
-
services = extensions["services"]
|
| 903 |
-
base_dir = extensions["base_dir"]
|
| 904 |
-
hf_sync = extensions.get("hf_sync")
|
| 905 |
-
from ..config import Paths
|
| 906 |
-
import shutil
|
| 907 |
-
|
| 908 |
-
if userid and userid not in ("admin", "default"):
|
| 909 |
-
logging.info("Purging session '%s' assets on UI exit...", userid)
|
| 910 |
-
print(f"[SESSION] Disconnected (User Exit): {userid}", flush=True)
|
| 911 |
-
global total_disconnections_this_hour
|
| 912 |
-
total_disconnections_this_hour += 1
|
| 913 |
-
|
| 914 |
-
for tok in user_auth.SessionManager.get_userid_sessions(userid):
|
| 915 |
-
user_auth.SessionManager.destroy_session(tok)
|
| 916 |
-
|
| 917 |
-
if userid in services:
|
| 918 |
-
del services[userid]
|
| 919 |
-
_active_sessions.pop(userid, None)
|
| 920 |
-
|
| 921 |
-
paths = Paths(base_dir=base_dir, session_token=userid)
|
| 922 |
-
try:
|
| 923 |
-
for f in (paths.state_file, paths.config_file, paths.portfolio_file):
|
| 924 |
-
if f.exists():
|
| 925 |
-
f.unlink()
|
| 926 |
-
if paths.watchlist_dir.exists():
|
| 927 |
-
shutil.rmtree(paths.watchlist_dir)
|
| 928 |
-
except Exception as exc:
|
| 929 |
-
logging.error("Error purging session '%s' assets: %s", userid, exc)
|
| 930 |
-
|
| 931 |
-
if hf_sync:
|
| 932 |
-
hf_sync.delete_session_files(userid)
|
| 933 |
-
|
| 934 |
-
return jsonify({"ok": True, "message": "UI session closed successfully."})
|
| 935 |
-
|
| 936 |
-
logging.info("Initiating graceful shutdown via API...")
|
| 937 |
-
|
| 938 |
-
# Mark as exiting
|
| 939 |
-
try:
|
| 940 |
-
_svc().is_exiting = True
|
| 941 |
-
except Exception:
|
| 942 |
-
pass
|
| 943 |
-
|
| 944 |
-
# Trigger SIGINT process termination (handled by register_shutdown_handler in nsedash_app.py)
|
| 945 |
-
import sys
|
| 946 |
-
is_testing = current_app.testing or current_app.config.get("TESTING") or "pytest" in sys.modules
|
| 947 |
-
if not is_testing:
|
| 948 |
-
def kill_self():
|
| 949 |
-
time.sleep(0.1)
|
| 950 |
-
logging.info("Triggering SIGINT signal for process shutdown.")
|
| 951 |
-
os.kill(os.getpid(), signal.SIGINT)
|
| 952 |
-
threading.Thread(target=kill_self, daemon=True).start()
|
| 953 |
-
else:
|
| 954 |
-
# Under tests, we manually run cleanup since we won't SIGINT the test process
|
| 955 |
-
logging.info("Testing mode: performing manual cleanup instead of SIGINT.")
|
| 956 |
-
try:
|
| 957 |
-
_scheduler().stop()
|
| 958 |
-
except Exception:
|
| 959 |
-
pass
|
| 960 |
-
try:
|
| 961 |
-
scripts = _scripts()
|
| 962 |
-
for run in list(scripts._runs.values()):
|
| 963 |
-
if run.process and run.process.poll() is None:
|
| 964 |
-
run.process.terminate()
|
| 965 |
-
try:
|
| 966 |
-
run.process.wait(timeout=1.0)
|
| 967 |
-
except Exception:
|
| 968 |
-
run.process.kill()
|
| 969 |
-
except Exception:
|
| 970 |
-
pass
|
| 971 |
-
try:
|
| 972 |
-
runtime = current_app.extensions["nsedash"]["runtime"]
|
| 973 |
-
runtime.shutdown()
|
| 974 |
-
except Exception:
|
| 975 |
-
pass
|
| 976 |
-
|
| 977 |
-
return jsonify({"ok": True, "message": "Backend shutting down..."})
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
# ---------------------------------------------------------------------------
|
| 981 |
-
# Admin API endpoints
|
| 982 |
-
# ---------------------------------------------------------------------------
|
| 983 |
-
|
| 984 |
-
def is_admin_request() -> bool:
|
| 985 |
-
token = (request.headers.get("X-Session-Token")
|
| 986 |
-
or request.cookies.get("session_token")
|
| 987 |
-
or request.args.get("session_token"))
|
| 988 |
-
if not token:
|
| 989 |
-
return False
|
| 990 |
-
valid, userid = user_auth.SessionManager.validate_session(token)
|
| 991 |
-
if not valid:
|
| 992 |
-
return False
|
| 993 |
-
if userid == "admin":
|
| 994 |
-
return True
|
| 995 |
-
u = user_auth.UserDatabase.get_user(userid)
|
| 996 |
-
return bool(u and u.get("is_admin", False))
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
@api.get("/admin/dashboard")
|
| 1000 |
-
def admin_dashboard():
|
| 1001 |
-
if not is_admin_request():
|
| 1002 |
-
return jsonify({"error": "Unauthorized"}), 403
|
| 1003 |
-
|
| 1004 |
-
all_users = user_auth.UserDatabase.get_all_users()
|
| 1005 |
-
|
| 1006 |
-
# Calculate some metrics
|
| 1007 |
-
extensions = current_app.extensions["nsedash"]
|
| 1008 |
-
service = extensions.get("service") or _svc()
|
| 1009 |
-
|
| 1010 |
-
from ..config import NIFTY_STOCKS, SENSEX_STOCKS
|
| 1011 |
-
total_symbols = len(set(NIFTY_STOCKS + SENSEX_STOCKS))
|
| 1012 |
-
|
| 1013 |
-
# Check yfinance connection status (parity with mlbench)
|
| 1014 |
-
yfinance_ok = True
|
| 1015 |
-
try:
|
| 1016 |
-
import requests
|
| 1017 |
-
r = requests.get(
|
| 1018 |
-
"https://query1.finance.yahoo.com/v8/finance/chart/RELIANCE.NS?interval=1d&range=1d",
|
| 1019 |
-
headers={"User-Agent": "Mozilla/5.0"},
|
| 1020 |
-
timeout=1.5
|
| 1021 |
-
)
|
| 1022 |
-
yfinance_ok = (r.status_code == 200)
|
| 1023 |
-
except Exception:
|
| 1024 |
-
yfinance_ok = False
|
| 1025 |
-
|
| 1026 |
-
return jsonify({
|
| 1027 |
-
"ok": True,
|
| 1028 |
-
"total_users": len(all_users),
|
| 1029 |
-
"total_symbols": total_symbols,
|
| 1030 |
-
"yfinance_connected": yfinance_ok,
|
| 1031 |
-
"users": all_users,
|
| 1032 |
-
})
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
@api.post("/admin/action")
|
| 1036 |
-
def admin_action():
|
| 1037 |
-
if not is_admin_request():
|
| 1038 |
-
return jsonify({"error": "Unauthorized"}), 403
|
| 1039 |
-
|
| 1040 |
-
body = request.get_json(force=True)
|
| 1041 |
-
action = body.get("action")
|
| 1042 |
-
target_uid = body.get("target_uid")
|
| 1043 |
-
|
| 1044 |
-
admin_user = user_auth.SessionManager.validate_session(
|
| 1045 |
-
request.headers.get("X-Session-Token") or request.cookies.get("session_token")
|
| 1046 |
-
)[1]
|
| 1047 |
-
|
| 1048 |
-
if action == "add_user":
|
| 1049 |
-
username = body.get("username", "").strip()
|
| 1050 |
-
role = body.get("role", "user")
|
| 1051 |
-
if not username:
|
| 1052 |
-
return jsonify({"ok": False, "message": "Username is required."}), 400
|
| 1053 |
-
is_adm = (role == "admin")
|
| 1054 |
-
created = user_auth.UserDatabase.create_user(username, is_admin=is_adm)
|
| 1055 |
-
if created:
|
| 1056 |
-
user_auth.ActivityLogger.log_activity(admin_user, "add_user", "success", f"Created user {username} with role {role}")
|
| 1057 |
-
return jsonify({"ok": True, "message": f"User '{username}' created successfully."})
|
| 1058 |
-
return jsonify({"ok": False, "message": "Failed to create user. User may already exist."}), 400
|
| 1059 |
-
|
| 1060 |
-
elif action == "qr":
|
| 1061 |
-
qr_uri = user_auth.AuthenticationService.get_totp_qr(target_uid)
|
| 1062 |
-
user = user_auth.UserDatabase.get_user(target_uid) or {}
|
| 1063 |
-
secret = user.get("totp_secret", "Not Set")
|
| 1064 |
-
return jsonify({"ok": True, "qr": qr_uri, "secret": secret})
|
| 1065 |
-
|
| 1066 |
-
elif action == "metrics":
|
| 1067 |
-
user = user_auth.UserDatabase.get_user(target_uid)
|
| 1068 |
-
if not user:
|
| 1069 |
-
return jsonify({"ok": False, "message": "User not found"}), 404
|
| 1070 |
-
|
| 1071 |
-
# Load user's data to check symbols/watchlists
|
| 1072 |
-
user_data = user_auth.get_user_data(target_uid)
|
| 1073 |
-
state_data = user_data.get("state") or {}
|
| 1074 |
-
symbols_count = len(state_data.get("symbols", []))
|
| 1075 |
-
watchlists_count = len(user_data.get("watchlists", {}))
|
| 1076 |
-
|
| 1077 |
-
return jsonify({
|
| 1078 |
-
"ok": True,
|
| 1079 |
-
"userid": target_uid,
|
| 1080 |
-
"login_count": user.get("login_count", 0),
|
| 1081 |
-
"last_login": user.get("last_login", "Never"),
|
| 1082 |
-
"symbols_count": symbols_count,
|
| 1083 |
-
"watchlists_count": watchlists_count,
|
| 1084 |
-
})
|
| 1085 |
-
|
| 1086 |
-
elif action == "reset":
|
| 1087 |
-
if target_uid == admin_user:
|
| 1088 |
-
return jsonify({"ok": False, "message": "Cannot reset your own account while logged in."}), 400
|
| 1089 |
-
res = user_auth.UserDatabase.reset_totp(target_uid)
|
| 1090 |
-
if res:
|
| 1091 |
-
user_auth.SessionManager.clear_sessions_for_user(target_uid)
|
| 1092 |
-
user_auth.ActivityLogger.log_activity(admin_user, "reset_account", "success", f"Full reset for {target_uid}")
|
| 1093 |
-
return jsonify({"ok": True, "message": f"Full account reset for {target_uid}. They have been logged out."})
|
| 1094 |
-
return jsonify({"ok": False, "message": "Failed to reset account."}), 400
|
| 1095 |
-
|
| 1096 |
-
elif action == "lock":
|
| 1097 |
-
if target_uid == admin_user:
|
| 1098 |
-
return jsonify({"ok": False, "message": "Cannot lock your own admin account."}), 400
|
| 1099 |
-
user = user_auth.UserDatabase.get_user(target_uid)
|
| 1100 |
-
if user:
|
| 1101 |
-
new_status = not user.get("active", True)
|
| 1102 |
-
user_auth.UserDatabase.update_user(target_uid, {"active": new_status})
|
| 1103 |
-
status_str = "unlocked" if new_status else "locked"
|
| 1104 |
-
user_auth.ActivityLogger.log_activity(admin_user, "lock_user", "success", f"Toggled status of {target_uid} to {status_str}")
|
| 1105 |
-
return jsonify({"ok": True, "message": f"User '{target_uid}' account status toggled to {status_str}."})
|
| 1106 |
-
return jsonify({"ok": False, "message": "User not found."}), 404
|
| 1107 |
-
|
| 1108 |
-
elif action == "delete":
|
| 1109 |
-
if target_uid == admin_user:
|
| 1110 |
-
return jsonify({"ok": False, "message": "Cannot delete your own admin account."}), 400
|
| 1111 |
-
res = user_auth.UserDatabase.delete_user(target_uid)
|
| 1112 |
-
if res:
|
| 1113 |
-
user_auth.ActivityLogger.log_activity(admin_user, "delete_user", "success", f"Permanently deleted user {target_uid}")
|
| 1114 |
-
return jsonify({"ok": True, "message": f"User '{target_uid}' has been deleted."})
|
| 1115 |
-
return jsonify({"ok": False, "message": "Failed to delete user."}), 400
|
| 1116 |
-
|
| 1117 |
-
elif action == "mirror":
|
| 1118 |
-
logs = user_auth.ActivityLogger.get_activity_logs(target_uid, limit=20)
|
| 1119 |
-
return jsonify({"ok": True, "logs": logs})
|
| 1120 |
-
|
| 1121 |
-
elif action == "chat":
|
| 1122 |
-
# Port chat functionality: return simulated message log with the user
|
| 1123 |
-
msgs = [
|
| 1124 |
-
{"sender": "system", "text": f"Connected to {target_uid} session channel..."},
|
| 1125 |
-
{"sender": "system", "text": "No live messages in queue. Showing connection log."}
|
| 1126 |
-
]
|
| 1127 |
-
return jsonify({"ok": True, "messages": msgs})
|
| 1128 |
-
|
| 1129 |
-
return jsonify({"ok": False, "message": "Unknown action."}), 400
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
@api.get("/stocks/<symbol>/ai-analysis")
|
| 1133 |
-
def get_ai_analysis(symbol: str):
|
| 1134 |
-
"""
|
| 1135 |
-
Get or generate AI-driven stock analysis in markdown format using the 5-pipeline
|
| 1136 |
-
agentic research analyst (nsedash_research.py). Non-blocking background mode.
|
| 1137 |
-
"""
|
| 1138 |
-
token = get_session_token()
|
| 1139 |
-
base_dir = current_app.extensions["nsedash"]["base_dir"]
|
| 1140 |
-
force_refresh = request.args.get("force", "0") == "1"
|
| 1141 |
-
|
| 1142 |
-
# Extract user-selected model and timeout
|
| 1143 |
-
req_json = request.get_json(silent=True) or {}
|
| 1144 |
-
selected_model = request.args.get("model") or req_json.get("model") or "google/diffusiongemma-26b-a4b-it"
|
| 1145 |
-
timeout_raw = request.args.get("timeout") or req_json.get("timeout") or 5
|
| 1146 |
-
try:
|
| 1147 |
-
timeout_mins = int(timeout_raw)
|
| 1148 |
-
timeout_sec = timeout_mins * 60 if timeout_mins <= 60 else timeout_mins
|
| 1149 |
-
except (ValueError, TypeError):
|
| 1150 |
-
timeout_sec = 300
|
| 1151 |
-
|
| 1152 |
-
import sys
|
| 1153 |
-
sys_path = str(Path(base_dir))
|
| 1154 |
-
if sys_path not in sys.path:
|
| 1155 |
-
sys.path.insert(0, sys_path)
|
| 1156 |
-
from nsedash_research import NSEMarketResearchAnalyst
|
| 1157 |
-
|
| 1158 |
-
analyst = NSEMarketResearchAnalyst(base_dir=base_dir)
|
| 1159 |
-
cache_file = analyst.aianalysis_dir / f"{symbol.upper()}_agentic.md"
|
| 1160 |
-
|
| 1161 |
-
# If cached and not force_refresh, return immediately!
|
| 1162 |
-
if not force_refresh and cache_file.exists():
|
| 1163 |
-
try:
|
| 1164 |
-
mtime = cache_file.stat().st_mtime
|
| 1165 |
-
if time.time() - mtime < 24 * 3600:
|
| 1166 |
-
result = analyst.run_full_research_pipeline(symbol, force_refresh=False, selected_model=selected_model, timeout_seconds=timeout_sec)
|
| 1167 |
-
return jsonify(result)
|
| 1168 |
-
except Exception:
|
| 1169 |
-
pass
|
| 1170 |
-
|
| 1171 |
-
# Submit background job to prevent blocking Flask worker / UI thread
|
| 1172 |
-
jobs = _jobs()
|
| 1173 |
-
|
| 1174 |
-
async def run():
|
| 1175 |
-
res = analyst.run_full_research_pipeline(symbol, force_refresh=True, selected_model=selected_model, timeout_seconds=timeout_sec)
|
| 1176 |
-
# Sync with HF dataset if enabled
|
| 1177 |
-
from ..config import Paths
|
| 1178 |
-
paths = Paths(base_dir=base_dir, session_token=token)
|
| 1179 |
-
file_path = paths.aianalysis_dir / f"{symbol}.md"
|
| 1180 |
-
hf_sync = current_app.extensions["nsedash"].get("hf_sync")
|
| 1181 |
-
if hf_sync and file_path.exists():
|
| 1182 |
-
hf_sync.push_file_async(file_path)
|
| 1183 |
-
return res
|
| 1184 |
-
|
| 1185 |
-
job_id = jobs.submit(f"AI Agentic Research for {symbol}", run)
|
| 1186 |
-
return jsonify({"ok": True, "job_id": job_id, "symbol": symbol, "status": "running", "cached": False, "selected_model": selected_model, "timeout_seconds": timeout_sec})
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
@api.post("/stocks/<symbol>/ai-analysis/start")
|
| 1190 |
-
def start_ai_analysis(symbol: str):
|
| 1191 |
-
"""Explicitly trigger fresh background AI agentic research."""
|
| 1192 |
-
return get_ai_analysis(symbol)
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/background.py
DELETED
|
@@ -1,281 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
nsedash.web.background
|
| 3 |
-
======================
|
| 4 |
-
Async runtime for the web application.
|
| 5 |
-
|
| 6 |
-
- ``AsyncRuntime``: a dedicated thread running an asyncio event loop. All
|
| 7 |
-
crews and long operations execute here, so Flask request handlers never
|
| 8 |
-
block on analysis, scraping, or training (the web equivalent of v4.5's
|
| 9 |
-
``_run_long_operation`` daemon threads + wait dialogs).
|
| 10 |
-
- ``JobManager``: submit coroutine factories, poll status/result by job id.
|
| 11 |
-
The frontend polls ``/api/jobs/<id>`` and shows a spinner — the UX role of
|
| 12 |
-
the v4.5 ``wait_dialog_decorator``.
|
| 13 |
-
- ``LiveRefreshScheduler``: asyncio task replicating the ``root.after``
|
| 14 |
-
polling loop (fetch immediately, then every N minutes while active).
|
| 15 |
-
- ``ScriptRunner``: interactive subprocess execution with a scrollback
|
| 16 |
-
buffer and stdin injection — the web replacement for v4.5's
|
| 17 |
-
``run_script_in_shell`` Tk shell window.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
from __future__ import annotations
|
| 21 |
-
|
| 22 |
-
import asyncio
|
| 23 |
-
import itertools
|
| 24 |
-
import logging
|
| 25 |
-
import os
|
| 26 |
-
import subprocess
|
| 27 |
-
import sys
|
| 28 |
-
import threading
|
| 29 |
-
import time
|
| 30 |
-
import uuid
|
| 31 |
-
from dataclasses import dataclass, field
|
| 32 |
-
from typing import Any, Callable, Coroutine
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class AsyncRuntime:
|
| 36 |
-
"""Dedicated asyncio loop in a daemon thread."""
|
| 37 |
-
|
| 38 |
-
def __init__(self):
|
| 39 |
-
self.loop = asyncio.new_event_loop()
|
| 40 |
-
self._thread = threading.Thread(
|
| 41 |
-
target=self._run, name="nsedash-async", daemon=True
|
| 42 |
-
)
|
| 43 |
-
self._thread.start()
|
| 44 |
-
|
| 45 |
-
def _run(self):
|
| 46 |
-
asyncio.set_event_loop(self.loop)
|
| 47 |
-
self.loop.run_forever()
|
| 48 |
-
|
| 49 |
-
def submit(self, coro: Coroutine) -> "asyncio.Future":
|
| 50 |
-
return asyncio.run_coroutine_threadsafe(coro, self.loop)
|
| 51 |
-
|
| 52 |
-
def shutdown(self):
|
| 53 |
-
self.loop.call_soon_threadsafe(self.loop.stop)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
@dataclass
|
| 57 |
-
class Job:
|
| 58 |
-
id: str
|
| 59 |
-
description: str
|
| 60 |
-
status: str = "running" # running | done | error
|
| 61 |
-
result: Any = None
|
| 62 |
-
error: str | None = None
|
| 63 |
-
created_at: float = field(default_factory=time.time)
|
| 64 |
-
finished_at: float | None = None
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
class JobManager:
|
| 68 |
-
|
| 69 |
-
def __init__(self, runtime: AsyncRuntime):
|
| 70 |
-
self.runtime = runtime
|
| 71 |
-
self._jobs: dict[str, Job] = {}
|
| 72 |
-
self._lock = threading.Lock()
|
| 73 |
-
|
| 74 |
-
def submit(self, description: str,
|
| 75 |
-
coro_factory: Callable[[], Coroutine]) -> str:
|
| 76 |
-
job = Job(id=uuid.uuid4().hex[:12], description=description)
|
| 77 |
-
with self._lock:
|
| 78 |
-
self._jobs[job.id] = job
|
| 79 |
-
|
| 80 |
-
async def runner():
|
| 81 |
-
try:
|
| 82 |
-
job.result = await coro_factory()
|
| 83 |
-
job.status = "done"
|
| 84 |
-
except Exception as e:
|
| 85 |
-
logging.error(f"Job '{description}' failed: {e}")
|
| 86 |
-
job.status = "error"
|
| 87 |
-
job.error = str(e)
|
| 88 |
-
finally:
|
| 89 |
-
job.finished_at = time.time()
|
| 90 |
-
|
| 91 |
-
self.runtime.submit(runner())
|
| 92 |
-
return job.id
|
| 93 |
-
|
| 94 |
-
def get(self, job_id: str) -> Job | None:
|
| 95 |
-
with self._lock:
|
| 96 |
-
return self._jobs.get(job_id)
|
| 97 |
-
|
| 98 |
-
def to_payload(self, job: Job) -> dict:
|
| 99 |
-
return {
|
| 100 |
-
"id": job.id, "description": job.description,
|
| 101 |
-
"status": job.status, "result": job.result, "error": job.error,
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
class LiveRefreshScheduler:
|
| 106 |
-
"""Replicates the v4.5 root.after() live polling loop with asyncio and triggers background nsemaxlive.py."""
|
| 107 |
-
|
| 108 |
-
def __init__(self, runtime: AsyncRuntime, refresh_callable: Callable[[], dict], base_dir: Any = None):
|
| 109 |
-
self.runtime = runtime
|
| 110 |
-
self.refresh_callable = refresh_callable
|
| 111 |
-
self.base_dir = str(base_dir) if base_dir else None
|
| 112 |
-
self._task: asyncio.Task | None = None
|
| 113 |
-
self._interval_min: int = 5
|
| 114 |
-
|
| 115 |
-
@property
|
| 116 |
-
def active(self) -> bool:
|
| 117 |
-
return self._task is not None and not self._task.done()
|
| 118 |
-
|
| 119 |
-
def _run_maxlive_script(self):
|
| 120 |
-
if not self.base_dir:
|
| 121 |
-
return
|
| 122 |
-
script_path = os.path.join(self.base_dir, "nsemaxlive.py")
|
| 123 |
-
if os.path.exists(script_path):
|
| 124 |
-
try:
|
| 125 |
-
logging.info("[LiveScheduler] Triggering background nsemaxlive.py execution...")
|
| 126 |
-
res = subprocess.run([sys.executable, "-u", script_path], cwd=self.base_dir, timeout=300, capture_output=True, text=True)
|
| 127 |
-
if res.returncode == 0:
|
| 128 |
-
logging.info("[LiveScheduler] Background nsemaxlive.py finished successfully.")
|
| 129 |
-
else:
|
| 130 |
-
logging.warning(f"[LiveScheduler] Background nsemaxlive.py exited with code {res.returncode}: {res.stderr[:200]}")
|
| 131 |
-
except Exception as exc:
|
| 132 |
-
logging.error(f"[LiveScheduler] Error running background nsemaxlive.py: {exc}")
|
| 133 |
-
|
| 134 |
-
def start(self, interval_min: int) -> None:
|
| 135 |
-
self.stop()
|
| 136 |
-
self._interval_min = max(1, int(interval_min))
|
| 137 |
-
|
| 138 |
-
async def poll_loop():
|
| 139 |
-
loop = asyncio.get_running_loop()
|
| 140 |
-
while True:
|
| 141 |
-
try:
|
| 142 |
-
# Blocking scrape work runs in executor
|
| 143 |
-
await loop.run_in_executor(None, self.refresh_callable)
|
| 144 |
-
except Exception as e:
|
| 145 |
-
logging.error(f"Live refresh cycle failed: {e}")
|
| 146 |
-
|
| 147 |
-
try:
|
| 148 |
-
# Run nsemaxlive.py in background executor
|
| 149 |
-
await loop.run_in_executor(None, self._run_maxlive_script)
|
| 150 |
-
except Exception as e:
|
| 151 |
-
logging.error(f"Live maxlive background script failed: {e}")
|
| 152 |
-
|
| 153 |
-
await asyncio.sleep(self._interval_min * 60)
|
| 154 |
-
|
| 155 |
-
def create():
|
| 156 |
-
self._task = self.runtime.loop.create_task(poll_loop())
|
| 157 |
-
|
| 158 |
-
self.runtime.loop.call_soon_threadsafe(create)
|
| 159 |
-
|
| 160 |
-
def stop(self) -> None:
|
| 161 |
-
task = self._task
|
| 162 |
-
self._task = None
|
| 163 |
-
if task and not task.done():
|
| 164 |
-
self.runtime.loop.call_soon_threadsafe(task.cancel)
|
| 165 |
-
logging.info("Periodic live data refresh stopped.")
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
class ScriptRun:
|
| 169 |
-
"""One interactive subprocess with buffered, tagged output lines."""
|
| 170 |
-
|
| 171 |
-
def __init__(self, script_name: str, script_dir: str):
|
| 172 |
-
self.id = uuid.uuid4().hex[:12]
|
| 173 |
-
self.script_name = script_name
|
| 174 |
-
self.script_dir = script_dir
|
| 175 |
-
self._lines: list[dict] = []
|
| 176 |
-
self._seq = itertools.count()
|
| 177 |
-
self._lock = threading.Lock()
|
| 178 |
-
self.process: subprocess.Popen | None = None
|
| 179 |
-
self.returncode: int | None = None
|
| 180 |
-
self._start()
|
| 181 |
-
|
| 182 |
-
def _log(self, message: str, tag: str = "stdout") -> None:
|
| 183 |
-
with self._lock:
|
| 184 |
-
self._lines.append(
|
| 185 |
-
{"n": next(self._seq), "text": message, "tag": tag}
|
| 186 |
-
)
|
| 187 |
-
|
| 188 |
-
def _start(self) -> None:
|
| 189 |
-
self._log(f"Starting script from directory: {self.script_dir}", "info")
|
| 190 |
-
self._log(f"Executing command: python -u {self.script_name}", "info")
|
| 191 |
-
self._log("-" * 80, "info")
|
| 192 |
-
|
| 193 |
-
def run():
|
| 194 |
-
try:
|
| 195 |
-
creationflags = 0
|
| 196 |
-
if os.name == 'nt':
|
| 197 |
-
creationflags = subprocess.CREATE_NO_WINDOW
|
| 198 |
-
# AUDIT FIX M-11: use sys.executable, not bare 'python',
|
| 199 |
-
# which resolves differently (or not at all) per PATH.
|
| 200 |
-
self.process = subprocess.Popen(
|
| 201 |
-
[sys.executable, '-u', self.script_name],
|
| 202 |
-
cwd=self.script_dir,
|
| 203 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 204 |
-
stdin=subprocess.PIPE, text=True,
|
| 205 |
-
encoding='utf-8', errors='replace',
|
| 206 |
-
bufsize=1, creationflags=creationflags,
|
| 207 |
-
)
|
| 208 |
-
|
| 209 |
-
def read_pipe(pipe, tag):
|
| 210 |
-
for line in iter(pipe.readline, ''):
|
| 211 |
-
self._log(line.rstrip("\n"), tag)
|
| 212 |
-
pipe.close()
|
| 213 |
-
|
| 214 |
-
t_out = threading.Thread(
|
| 215 |
-
target=read_pipe, args=(self.process.stdout, "stdout"),
|
| 216 |
-
daemon=True)
|
| 217 |
-
t_err = threading.Thread(
|
| 218 |
-
target=read_pipe, args=(self.process.stderr, "error"),
|
| 219 |
-
daemon=True)
|
| 220 |
-
t_out.start(); t_err.start()
|
| 221 |
-
rc = self.process.wait()
|
| 222 |
-
t_out.join(); t_err.join()
|
| 223 |
-
self.returncode = rc
|
| 224 |
-
self._log("-" * 80, "info")
|
| 225 |
-
if rc == 0:
|
| 226 |
-
self._log(
|
| 227 |
-
f"Process '{self.script_name}' finished successfully "
|
| 228 |
-
f"(Exit Code: {rc}).", "success")
|
| 229 |
-
else:
|
| 230 |
-
self._log(
|
| 231 |
-
f"Process '{self.script_name}' finished with error "
|
| 232 |
-
f"(Exit Code: {rc}).", "error")
|
| 233 |
-
except FileNotFoundError:
|
| 234 |
-
self.returncode = -1
|
| 235 |
-
self._log(
|
| 236 |
-
f"Error: Script '{self.script_name}' not found in "
|
| 237 |
-
f"'{self.script_dir}'.", "error")
|
| 238 |
-
except Exception as e:
|
| 239 |
-
self.returncode = -1
|
| 240 |
-
self._log(
|
| 241 |
-
f"Unexpected error running '{self.script_name}': {e}",
|
| 242 |
-
"error")
|
| 243 |
-
finally:
|
| 244 |
-
self._log("Input disabled. Process has finished.", "info")
|
| 245 |
-
|
| 246 |
-
threading.Thread(target=run, daemon=True).start()
|
| 247 |
-
|
| 248 |
-
def send_input(self, text: str) -> bool:
|
| 249 |
-
text = (text or "").strip()
|
| 250 |
-
if not text or self.process is None or self.process.poll() is not None:
|
| 251 |
-
return False
|
| 252 |
-
try:
|
| 253 |
-
self._log(f"> {text}", "input")
|
| 254 |
-
self.process.stdin.write(text + '\n')
|
| 255 |
-
self.process.stdin.flush()
|
| 256 |
-
return True
|
| 257 |
-
except (IOError, BrokenPipeError) as e:
|
| 258 |
-
self._log(f"Error: Could not write to process. {e}", "error")
|
| 259 |
-
return False
|
| 260 |
-
|
| 261 |
-
def lines_since(self, after: int = -1) -> list[dict]:
|
| 262 |
-
with self._lock:
|
| 263 |
-
return [ln for ln in self._lines if ln["n"] > after]
|
| 264 |
-
|
| 265 |
-
@property
|
| 266 |
-
def finished(self) -> bool:
|
| 267 |
-
return self.returncode is not None
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
class ScriptRunner:
|
| 271 |
-
|
| 272 |
-
def __init__(self):
|
| 273 |
-
self._runs: dict[str, ScriptRun] = {}
|
| 274 |
-
|
| 275 |
-
def start(self, script_name: str, script_dir: str) -> ScriptRun:
|
| 276 |
-
run = ScriptRun(script_name, script_dir)
|
| 277 |
-
self._runs[run.id] = run
|
| 278 |
-
return run
|
| 279 |
-
|
| 280 |
-
def get(self, run_id: str) -> ScriptRun | None:
|
| 281 |
-
return self._runs.get(run_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/routes.py
DELETED
|
@@ -1,14 +0,0 @@
|
|
| 1 |
-
import uuid
|
| 2 |
-
from flask import Blueprint, render_template, request, make_response
|
| 3 |
-
|
| 4 |
-
pages = Blueprint("pages", __name__)
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
@pages.get("/")
|
| 8 |
-
def index():
|
| 9 |
-
token = request.cookies.get("session_token")
|
| 10 |
-
response = make_response(render_template("index.html"))
|
| 11 |
-
if not token:
|
| 12 |
-
token = uuid.uuid4().hex[:12]
|
| 13 |
-
response.set_cookie("session_token", token, max_age=30*86400, httponly=True, samesite='Lax', secure=request.is_secure)
|
| 14 |
-
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/static/app.js
DELETED
|
@@ -1,1548 +0,0 @@
|
|
| 1 |
-
/* NSE Stock Dashboard — frontend controller.
|
| 2 |
-
* Mirrors every v4.5 desktop interaction against the REST API. */
|
| 3 |
-
|
| 4 |
-
"use strict";
|
| 5 |
-
|
| 6 |
-
const $ = (sel) => document.querySelector(sel);
|
| 7 |
-
const state = { board: null, charts: {}, livePoll: null };
|
| 8 |
-
|
| 9 |
-
function esc(s) {
|
| 10 |
-
if (s === null || s === undefined) return "";
|
| 11 |
-
return String(s)
|
| 12 |
-
.replace(/&/g, "&")
|
| 13 |
-
.replace(/</g, "<")
|
| 14 |
-
.replace(/>/g, ">")
|
| 15 |
-
.replace(/"/g, """)
|
| 16 |
-
.replace(/'/g, "'");
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
/* ---------------------------------------------------------------- utils */
|
| 20 |
-
|
| 21 |
-
async function api(path, opts = {}) {
|
| 22 |
-
const token = localStorage.getItem("session_token") || "";
|
| 23 |
-
const headers = {
|
| 24 |
-
"Content-Type": "application/json",
|
| 25 |
-
"X-Session-Token": token,
|
| 26 |
-
...(opts.headers || {})
|
| 27 |
-
};
|
| 28 |
-
const res = await fetch("/api" + path, {
|
| 29 |
-
...opts,
|
| 30 |
-
headers,
|
| 31 |
-
});
|
| 32 |
-
let body = {};
|
| 33 |
-
try { body = await res.json(); } catch (e) { /* empty */ }
|
| 34 |
-
if (res.status === 401 && body.auth_required) {
|
| 35 |
-
showLoginBox();
|
| 36 |
-
if (body.reason === "inactivity") {
|
| 37 |
-
toast("Session terminated due to inactivity.", "err");
|
| 38 |
-
const loginError = $("#login-error");
|
| 39 |
-
if (loginError) {
|
| 40 |
-
loginError.textContent = "Session terminated due to inactivity.";
|
| 41 |
-
loginError.classList.remove("hidden");
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
return body;
|
| 45 |
-
}
|
| 46 |
-
if (!res.ok && body.message) toast(body.message, "err");
|
| 47 |
-
return body;
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
function toast(msg, kind = "") {
|
| 51 |
-
const el = document.createElement("div");
|
| 52 |
-
el.className = "toast " + kind;
|
| 53 |
-
el.textContent = msg;
|
| 54 |
-
$("#toasts").appendChild(el);
|
| 55 |
-
setTimeout(() => el.remove(), 6000);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
function fmt(n, dp = 2) {
|
| 59 |
-
if (n === null || n === undefined || isNaN(n)) return "0.00";
|
| 60 |
-
return Number(n).toLocaleString("en-IN",
|
| 61 |
-
{ minimumFractionDigits: dp, maximumFractionDigits: dp });
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
function arrow(v) { return v > 0 ? "↑" : v < 0 ? "↓" : "↔"; }
|
| 65 |
-
function cls(v) { return v > 0 ? "gain" : v < 0 ? "loss" : "flat"; }
|
| 66 |
-
|
| 67 |
-
function scoreClass(s) {
|
| 68 |
-
if (s >= 80) return "score-80";
|
| 69 |
-
if (s >= 60) return "score-60";
|
| 70 |
-
if (s >= 40) return "score-40";
|
| 71 |
-
if (s >= 20) return "score-20";
|
| 72 |
-
return "score-0";
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
/* Busy overlay + job polling (v4.5 wait dialog equivalent) */
|
| 76 |
-
function busy(text) {
|
| 77 |
-
$("#busy-text").textContent = text || "Processing…";
|
| 78 |
-
$("#busy").classList.remove("hidden");
|
| 79 |
-
}
|
| 80 |
-
function unbusy() { $("#busy").classList.add("hidden"); }
|
| 81 |
-
|
| 82 |
-
async function runJob(startPromise, description) {
|
| 83 |
-
busy(description);
|
| 84 |
-
try {
|
| 85 |
-
const start = await startPromise;
|
| 86 |
-
if (!start.job_id) { unbusy(); return start; }
|
| 87 |
-
while (true) {
|
| 88 |
-
await new Promise((r) => setTimeout(r, 700));
|
| 89 |
-
const job = await api("/jobs/" + start.job_id);
|
| 90 |
-
if (job.status === "done") { unbusy(); return job.result || {}; }
|
| 91 |
-
if (job.status === "error") {
|
| 92 |
-
unbusy();
|
| 93 |
-
toast(job.error || "Operation failed.", "err");
|
| 94 |
-
return { ok: false, message: job.error };
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
} catch (e) {
|
| 98 |
-
unbusy();
|
| 99 |
-
toast(String(e), "err");
|
| 100 |
-
return { ok: false };
|
| 101 |
-
}
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
/* ---------------------------------------------------------------- board */
|
| 105 |
-
|
| 106 |
-
async function loadBoard() {
|
| 107 |
-
state.board = await api("/board");
|
| 108 |
-
render();
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
function render() {
|
| 112 |
-
const b = state.board;
|
| 113 |
-
if (!b) return;
|
| 114 |
-
|
| 115 |
-
// Banner
|
| 116 |
-
const banner = $("#banner");
|
| 117 |
-
banner.className = "banner " + (b.banner.mode === "live" ? "live" : "historic");
|
| 118 |
-
$("#banner-text").textContent = b.banner.mode === "live"
|
| 119 |
-
? `Live Data — ${b.banner.source} (every ${b.banner.refresh_rate} min)`
|
| 120 |
-
: "Historic Data";
|
| 121 |
-
$("#banner-watchlist").textContent =
|
| 122 |
-
b.banner.watchlist + (b.watchlist_modified ? " *" : "");
|
| 123 |
-
$("#live-toggle").checked = b.banner.mode === "live";
|
| 124 |
-
$("#date-input").value = b.selected_date;
|
| 125 |
-
$("#date-input").max = new Date().toISOString().slice(0, 10);
|
| 126 |
-
|
| 127 |
-
// Search datalist
|
| 128 |
-
const dl = $("#stock-list");
|
| 129 |
-
dl.innerHTML = "";
|
| 130 |
-
for (const key of b.search_keys) {
|
| 131 |
-
const o = document.createElement("option");
|
| 132 |
-
o.value = key;
|
| 133 |
-
dl.appendChild(o);
|
| 134 |
-
}
|
| 135 |
-
$("#board-count").textContent = `${b.cells.length} stocks on board`;
|
| 136 |
-
|
| 137 |
-
// Live index badges (NIFTY & SENSEX)
|
| 138 |
-
if (b.indices) {
|
| 139 |
-
if (b.indices.NIFTY) {
|
| 140 |
-
const n = b.indices.NIFTY;
|
| 141 |
-
const isPos = n.change >= 0;
|
| 142 |
-
const sym = isPos ? "▲" : "▼";
|
| 143 |
-
const el = $("#nifty-val");
|
| 144 |
-
if (el) {
|
| 145 |
-
el.textContent = `${sym} ${n.formatted_price} (${n.formatted_change})`;
|
| 146 |
-
el.className = `idx-pill ${isPos ? "positive" : "negative"}`;
|
| 147 |
-
}
|
| 148 |
-
}
|
| 149 |
-
if (b.indices.SENSEX) {
|
| 150 |
-
const s = b.indices.SENSEX;
|
| 151 |
-
const isPos = s.change >= 0;
|
| 152 |
-
const sym = isPos ? "▲" : "▼";
|
| 153 |
-
const el = $("#sensex-val");
|
| 154 |
-
if (el) {
|
| 155 |
-
el.textContent = `${sym} ${s.formatted_price} (${s.formatted_change})`;
|
| 156 |
-
el.className = `idx-pill ${isPos ? "positive" : "negative"}`;
|
| 157 |
-
}
|
| 158 |
-
}
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
renderPortfolio(b.portfolio);
|
| 162 |
-
renderMenus(b);
|
| 163 |
-
renderGrid(b.cells);
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
/* RTC Clock in IST Timezone */
|
| 167 |
-
function updateRTC() {
|
| 168 |
-
try {
|
| 169 |
-
const options = {
|
| 170 |
-
timeZone: "Asia/Kolkata",
|
| 171 |
-
year: "numeric",
|
| 172 |
-
month: "short",
|
| 173 |
-
day: "2-digit",
|
| 174 |
-
hour: "2-digit",
|
| 175 |
-
minute: "2-digit",
|
| 176 |
-
second: "2-digit",
|
| 177 |
-
hour12: true
|
| 178 |
-
};
|
| 179 |
-
const istString = new Date().toLocaleString("en-IN", options) + " IST";
|
| 180 |
-
const rtcEl = document.getElementById("rtc-time");
|
| 181 |
-
if (rtcEl) rtcEl.textContent = istString;
|
| 182 |
-
} catch (e) {
|
| 183 |
-
console.error("RTC clock error:", e);
|
| 184 |
-
}
|
| 185 |
-
}
|
| 186 |
-
setInterval(updateRTC, 1000);
|
| 187 |
-
updateRTC();
|
| 188 |
-
|
| 189 |
-
function renderPortfolio(p) {
|
| 190 |
-
if (!p) return;
|
| 191 |
-
$("#pf-invested").textContent = fmt(p.invested);
|
| 192 |
-
$("#pf-current").textContent = fmt(p.current_value);
|
| 193 |
-
const day = $("#pf-day");
|
| 194 |
-
day.textContent = `${fmt(p.day_gain_abs)} (${fmt(p.day_gain_pct)}%)`;
|
| 195 |
-
day.className = cls(p.day_gain_abs);
|
| 196 |
-
const abs = $("#pf-abs");
|
| 197 |
-
abs.textContent = `${fmt(p.abs_returns_abs)} (${fmt(p.abs_returns_pct)}%)`;
|
| 198 |
-
abs.className = cls(p.abs_returns_abs);
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
/* ---------------------------------------------------------------- menus */
|
| 202 |
-
|
| 203 |
-
function menuButton(label, fn) {
|
| 204 |
-
const btn = document.createElement("button");
|
| 205 |
-
btn.textContent = label;
|
| 206 |
-
btn.setAttribute("role", "menuitem");
|
| 207 |
-
btn.addEventListener("click", () => { closeMenus(); fn(); });
|
| 208 |
-
return btn;
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
function renderMenus(b) {
|
| 212 |
-
// Watchlists: load 1..20, load from file, live analysis quick loads
|
| 213 |
-
const wl = $("#menu-watchlists");
|
| 214 |
-
wl.innerHTML = "";
|
| 215 |
-
b.watchlist_names.forEach((name, i) => {
|
| 216 |
-
const isPrivate = i < 5;
|
| 217 |
-
const label = isPrivate ? `🔒 ${i + 1}. ${name}` : `${i + 1}. ${name}`;
|
| 218 |
-
const btn = menuButton(label, () => loadWatchlist(i + 1));
|
| 219 |
-
if (isPrivate) {
|
| 220 |
-
btn.style.color = "#10b981";
|
| 221 |
-
btn.style.fontWeight = "bold";
|
| 222 |
-
}
|
| 223 |
-
wl.appendChild(btn);
|
| 224 |
-
});
|
| 225 |
-
wl.appendChild(Object.assign(document.createElement("div"), { className: "sep" }));
|
| 226 |
-
wl.appendChild(menuButton("Load watchlist from file…", pickWatchlistFile));
|
| 227 |
-
|
| 228 |
-
// Max/Min price analysis
|
| 229 |
-
const mm = $("#menu-maxmin");
|
| 230 |
-
mm.innerHTML = "";
|
| 231 |
-
for (const d of ["1D", "1W", "1M", "3M"]) {
|
| 232 |
-
for (const t of ["Max", "Min"]) {
|
| 233 |
-
mm.appendChild(menuButton(`${d} ${t} Price Change`,
|
| 234 |
-
() => priceAnalysis(d, t)));
|
| 235 |
-
}
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
// Volume analysis
|
| 239 |
-
const vol = $("#menu-volume");
|
| 240 |
-
vol.innerHTML = "";
|
| 241 |
-
for (const d of ["1D", "1W", "1M", "3M"]) {
|
| 242 |
-
vol.appendChild(menuButton(`${d} Max Volume`, () => volumeAnalysis(d)));
|
| 243 |
-
}
|
| 244 |
-
vol.appendChild(Object.assign(document.createElement("div"), { className: "sep" }));
|
| 245 |
-
vol.appendChild(menuButton(
|
| 246 |
-
`Set Volume Cutoff (now ${fmt(b.volume_cutoff, 0)})`, setVolumeCutoff));
|
| 247 |
-
|
| 248 |
-
// Live analysis file loads (top 3 menu items only)
|
| 249 |
-
const la = $("#menu-liveanalysis");
|
| 250 |
-
la.innerHTML = "";
|
| 251 |
-
la.appendChild(menuButton("Top 50 Volume Gainers (maxvol50)",
|
| 252 |
-
() => loadKnownFile("maxvol50")));
|
| 253 |
-
la.appendChild(menuButton("Top 50 Price Gainers (maxprice50)",
|
| 254 |
-
() => loadKnownFile("maxprice50")));
|
| 255 |
-
la.appendChild(menuButton("Top 50 Price Losers (maxfall50)",
|
| 256 |
-
() => loadKnownFile("maxfall50")));
|
| 257 |
-
|
| 258 |
-
// Simulation
|
| 259 |
-
const sim = $("#menu-simulation");
|
| 260 |
-
sim.innerHTML = "";
|
| 261 |
-
sim.appendChild(menuButton("Top 50 Score (maxscore50)",
|
| 262 |
-
() => loadKnownFile("maxscore50")));
|
| 263 |
-
sim.appendChild(menuButton("Top Score (Moneycontrol)", topScore));
|
| 264 |
-
for (const d of ["1W", "1M", "3M"]) {
|
| 265 |
-
sim.appendChild(menuButton(`${d} Max Return`, () => returnAnalysis(d)));
|
| 266 |
-
}
|
| 267 |
-
sim.appendChild(Object.assign(document.createElement("div"), { className: "sep" }));
|
| 268 |
-
sim.appendChild(menuButton("Buy/Sell Recommender", recommend));
|
| 269 |
-
|
| 270 |
-
// Utilities
|
| 271 |
-
loadUtilitiesMenu();
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
async function loadUtilitiesMenu() {
|
| 275 |
-
const u = $("#menu-utilities");
|
| 276 |
-
if (!u) return;
|
| 277 |
-
|
| 278 |
-
// Immediately render static items so Admin Control Panel is instantly available
|
| 279 |
-
u.innerHTML = "";
|
| 280 |
-
u.appendChild(menuButton("Deep Learning: Train All Models", trainModels));
|
| 281 |
-
u.appendChild(Object.assign(document.createElement("div"), { className: "sep" }));
|
| 282 |
-
u.appendChild(menuButton("Admin Control Panel", openAdminPanel));
|
| 283 |
-
|
| 284 |
-
try {
|
| 285 |
-
const res = await api("/scripts");
|
| 286 |
-
if (res && res.scripts) {
|
| 287 |
-
u.innerHTML = "";
|
| 288 |
-
(res.scripts || []).forEach((s) => {
|
| 289 |
-
u.appendChild(menuButton(s.label, () => startScript(s)));
|
| 290 |
-
});
|
| 291 |
-
u.appendChild(Object.assign(document.createElement("div"), { className: "sep" }));
|
| 292 |
-
u.appendChild(menuButton("Deep Learning: Train All Models", trainModels));
|
| 293 |
-
u.appendChild(Object.assign(document.createElement("div"), { className: "sep" }));
|
| 294 |
-
u.appendChild(menuButton("Admin Control Panel", openAdminPanel));
|
| 295 |
-
}
|
| 296 |
-
} catch (err) {
|
| 297 |
-
console.error("Error loading utility scripts:", err);
|
| 298 |
-
}
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
function closeMenus() {
|
| 302 |
-
document.querySelectorAll(".menu.open")
|
| 303 |
-
.forEach((m) => {
|
| 304 |
-
m.classList.remove("open");
|
| 305 |
-
const btn = m.querySelector(".menu-btn");
|
| 306 |
-
if (btn) btn.setAttribute("aria-expanded", "false");
|
| 307 |
-
});
|
| 308 |
-
}
|
| 309 |
-
document.querySelectorAll(".menu-btn").forEach((btn) => {
|
| 310 |
-
btn.setAttribute("aria-haspopup", "true");
|
| 311 |
-
btn.setAttribute("aria-expanded", "false");
|
| 312 |
-
btn.addEventListener("click", (e) => {
|
| 313 |
-
if (btn.id === "btn-exit") {
|
| 314 |
-
e.stopPropagation();
|
| 315 |
-
triggerExit();
|
| 316 |
-
return;
|
| 317 |
-
}
|
| 318 |
-
const menu = e.target.closest(".menu");
|
| 319 |
-
const wasOpen = menu.classList.contains("open");
|
| 320 |
-
closeMenus();
|
| 321 |
-
if (!wasOpen) {
|
| 322 |
-
menu.classList.add("open");
|
| 323 |
-
btn.setAttribute("aria-expanded", "true");
|
| 324 |
-
}
|
| 325 |
-
e.stopPropagation();
|
| 326 |
-
});
|
| 327 |
-
});
|
| 328 |
-
document.addEventListener("click", closeMenus);
|
| 329 |
-
|
| 330 |
-
/* ---------------------------------------------------------------- cards */
|
| 331 |
-
|
| 332 |
-
function renderGrid(cells) {
|
| 333 |
-
// Destroy old charts to avoid canvas leaks
|
| 334 |
-
Object.values(state.charts).forEach((c) => c.destroy());
|
| 335 |
-
state.charts = {};
|
| 336 |
-
const grid = $("#grid");
|
| 337 |
-
grid.innerHTML = "";
|
| 338 |
-
for (const c of cells) grid.appendChild(cardEl(c));
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
/* ------------------------------------------------------------- AI Agentic Research */
|
| 342 |
-
let currentAISymbol = null;
|
| 343 |
-
let currentAIAnalysisText = "";
|
| 344 |
-
|
| 345 |
-
async function openAIAnalysisModal(symbol, forceRefresh = false) {
|
| 346 |
-
currentAISymbol = symbol;
|
| 347 |
-
$("#modal-ai").classList.remove("hidden");
|
| 348 |
-
$("#ai-title").innerHTML = `🤖 Agentic AI Stock Research: <span style="color: #3b82f6; margin-left: 6px;">${esc(symbol)}</span>`;
|
| 349 |
-
|
| 350 |
-
// Show spinner initially
|
| 351 |
-
$("#ai-content").innerHTML = `<div class="loading-spinner" style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px; color: var(--muted);">
|
| 352 |
-
<div class="spinner" style="border: 3px solid rgba(255,255,255,0.1); border-top: 3px solid #3b82f6; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin-bottom: 12px;"></div>
|
| 353 |
-
<span>Connecting to 5-Pipeline AI Agentic Research for ${esc(symbol)}...</span>
|
| 354 |
-
</div>`;
|
| 355 |
-
|
| 356 |
-
const selectedModel = $("#select-primary-llm") ? $("#select-primary-llm").value : "google/diffusiongemma-26b-a4b-it";
|
| 357 |
-
const selectedTimeout = $("#select-llm-timeout") ? $("#select-llm-timeout").value : "5";
|
| 358 |
-
|
| 359 |
-
$("#ai-meta-model").innerHTML = `🤖 LLM: <b style="color: #3b82f6;">${esc(selectedModel)}</b>`;
|
| 360 |
-
$("#ai-meta-time").innerHTML = `⏱️ Time: <b style="color: #10b981;">--s (Max ${selectedTimeout}m)</b>`;
|
| 361 |
-
$("#ai-meta-tokens").innerHTML = `📊 Tokens: <b style="color: #cbd5e1;">In: -- | Out: -- | Total: --</b>`;
|
| 362 |
-
|
| 363 |
-
try {
|
| 364 |
-
const url = `/stocks/${encodeURIComponent(symbol)}/ai-analysis?model=${encodeURIComponent(selectedModel)}&timeout=${encodeURIComponent(selectedTimeout)}${forceRefresh ? '&force=1' : ''}`;
|
| 365 |
-
const payload = await api(url);
|
| 366 |
-
|
| 367 |
-
if (payload && payload.ok) {
|
| 368 |
-
if (payload.analysis) {
|
| 369 |
-
// Immediate cached / completed display!
|
| 370 |
-
renderAIAnalysisReport(payload);
|
| 371 |
-
} else if (payload.job_id && payload.status === "running") {
|
| 372 |
-
// Background job submitted!
|
| 373 |
-
$("#ai-content").innerHTML = `<div style="padding: 30px; text-align: center; color: #cbd5e1;">
|
| 374 |
-
<div class="spinner" style="border: 3px solid rgba(255,255,255,0.1); border-top: 3px solid #3b82f6; border-radius: 50%; width: 36px; height: 36px; animation: spin 1s linear infinite; margin: 0 auto 16px auto;"></div>
|
| 375 |
-
<h4 style="margin: 0 0 8px 0; color: #60a5fa; font-size: 16px;">5-Pipeline AI Agentic Research in Progress</h4>
|
| 376 |
-
<p style="font-size: 13px; color: #94a3b8; max-width: 500px; margin: 0 auto;">Running 5 research pipelines for <strong>${esc(symbol)}</strong> in background using <strong>${esc(selectedModel)}</strong> (Timeout: ${selectedTimeout} mins).<br/>You can close this modal and continue navigating NSEDASH. Live status is shown at bottom right.</p>
|
| 377 |
-
</div>`;
|
| 378 |
-
pollAIAgentBackground(symbol, payload.job_id);
|
| 379 |
-
}
|
| 380 |
-
} else {
|
| 381 |
-
$("#ai-content").innerHTML = `<span class="loss">Error: ${esc((payload && payload.message) || "Failed to start AI analysis")}</span>`;
|
| 382 |
-
}
|
| 383 |
-
} catch (err) {
|
| 384 |
-
$("#ai-content").innerHTML = `<span class="loss">Error: ${esc(err.message)}</span>`;
|
| 385 |
-
}
|
| 386 |
-
}
|
| 387 |
-
|
| 388 |
-
function renderAIAnalysisReport(payload) {
|
| 389 |
-
currentAIAnalysisText = payload.analysis || "";
|
| 390 |
-
$("#ai-content").innerHTML = marked.parse(currentAIAnalysisText);
|
| 391 |
-
|
| 392 |
-
const modelName = payload.llm_model || "z-ai/glm-5.2";
|
| 393 |
-
const elapsed = payload.elapsed_seconds !== undefined ? `${payload.elapsed_seconds}s` : "--s";
|
| 394 |
-
const pt = payload.prompt_tokens || 0;
|
| 395 |
-
const ct = payload.completion_tokens || 0;
|
| 396 |
-
const tt = payload.total_tokens || (pt + ct);
|
| 397 |
-
|
| 398 |
-
$("#ai-meta-model").innerHTML = `🤖 LLM: <b style="color: #3b82f6;">${esc(modelName)}</b>`;
|
| 399 |
-
$("#ai-meta-time").innerHTML = `⏱️ Time: <b style="color: #10b981;">${elapsed}</b>`;
|
| 400 |
-
$("#ai-meta-tokens").innerHTML = `📊 Tokens: <b style="color: #cbd5e1;">In: ${fmt(pt, 0)} | Out: ${fmt(ct, 0)} | Total: ${fmt(tt, 0)}</b>`;
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
function pollAIAgentBackground(symbol, jobId) {
|
| 404 |
-
const widget = $("#ai-bg-widget");
|
| 405 |
-
const spinner = $("#ai-bg-spinner");
|
| 406 |
-
const text = $("#ai-bg-text");
|
| 407 |
-
const viewBtn = $("#ai-bg-view-btn");
|
| 408 |
-
|
| 409 |
-
if (!widget) return;
|
| 410 |
-
widget.classList.remove("hidden");
|
| 411 |
-
spinner.style.display = "block";
|
| 412 |
-
text.textContent = `🤖 AI Agentic Research Running for ${symbol}...`;
|
| 413 |
-
viewBtn.style.display = "none";
|
| 414 |
-
|
| 415 |
-
const startTime = Date.now();
|
| 416 |
-
const pollInterval = setInterval(async () => {
|
| 417 |
-
// Timeout safeguard after 310s
|
| 418 |
-
if (Date.now() - startTime > 310000) {
|
| 419 |
-
clearInterval(pollInterval);
|
| 420 |
-
text.textContent = `❌ AI Research for ${symbol} timed out`;
|
| 421 |
-
spinner.style.display = "none";
|
| 422 |
-
setTimeout(() => widget.classList.add("hidden"), 8000);
|
| 423 |
-
return;
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
try {
|
| 427 |
-
const job = await api("/jobs/" + jobId);
|
| 428 |
-
if (!job) return;
|
| 429 |
-
|
| 430 |
-
if (job.status === "done") {
|
| 431 |
-
clearInterval(pollInterval);
|
| 432 |
-
const res = job.result || {};
|
| 433 |
-
text.textContent = `✔ AI Agentic Research Completed for ${symbol}!`;
|
| 434 |
-
spinner.style.display = "none";
|
| 435 |
-
viewBtn.style.display = "inline-block";
|
| 436 |
-
viewBtn.onclick = () => {
|
| 437 |
-
openAIAnalysisModal(symbol, false);
|
| 438 |
-
widget.classList.add("hidden");
|
| 439 |
-
};
|
| 440 |
-
|
| 441 |
-
toast(`AI Agentic Research report ready for ${symbol}`, "ok");
|
| 442 |
-
|
| 443 |
-
// If modal is currently open for this symbol, render immediately!
|
| 444 |
-
if (currentAISymbol === symbol && !$("#modal-ai").classList.contains("hidden")) {
|
| 445 |
-
renderAIAnalysisReport(res);
|
| 446 |
-
}
|
| 447 |
-
} else if (job.status === "error") {
|
| 448 |
-
clearInterval(pollInterval);
|
| 449 |
-
text.textContent = `❌ AI Research failed for ${symbol}`;
|
| 450 |
-
spinner.style.display = "none";
|
| 451 |
-
toast(job.error || `AI Research failed for ${symbol}`, "err");
|
| 452 |
-
setTimeout(() => widget.classList.add("hidden"), 8000);
|
| 453 |
-
}
|
| 454 |
-
} catch (e) {
|
| 455 |
-
console.error("Error polling AI job:", e);
|
| 456 |
-
}
|
| 457 |
-
}, 1500);
|
| 458 |
-
}
|
| 459 |
-
|
| 460 |
-
function cardEl(c) {
|
| 461 |
-
const card = document.createElement("div");
|
| 462 |
-
card.className = "card";
|
| 463 |
-
const ch = c.changes || {};
|
| 464 |
-
const mc = c.mc || {};
|
| 465 |
-
const pl = (c.quantity * c.current_cmp) - (c.quantity * c.price);
|
| 466 |
-
const plPct = c.quantity * c.price
|
| 467 |
-
? (pl / (c.quantity * c.price)) * 100 : 0;
|
| 468 |
-
|
| 469 |
-
card.innerHTML = `
|
| 470 |
-
<button class="rm" title="Remove">×</button>
|
| 471 |
-
<h4>${esc(c.symbol)}</h4>
|
| 472 |
-
<div class="sec">${esc(c.security || "")} — ${esc(c.tran_date || "")}</div>
|
| 473 |
-
<div class="row"><span class="lbl">Prev / Curr</span>
|
| 474 |
-
<b class="num">${fmt(c.prev_close)} / ${fmt(c.close_price)}</b></div>
|
| 475 |
-
<div class="row"><span class="lbl">MCap / FV / Score</span>
|
| 476 |
-
<b class="num">${fmt(mc.mcap)} / ${fmt(mc.fv, 0)} /
|
| 477 |
-
<span class="${scoreClass(mc.score)}">${fmt(mc.score, 0)}</span></b></div>
|
| 478 |
-
<div class="row"><span class="lbl">52wk Hi (Δ)</span>
|
| 479 |
-
<b class="num">${fmt(c.hi_52wk)} <span class="loss">(${fmt(c.hi_diff)}%)</span></b></div>
|
| 480 |
-
<div class="row"><span class="lbl">52wk Lo (Δ)</span>
|
| 481 |
-
<b class="num">${fmt(c.lo_52wk)} <span class="gain">(${fmt(c.lo_diff)}%)</span></b></div>
|
| 482 |
-
<div class="row"><span class="lbl">ATH / ATL</span>
|
| 483 |
-
<b class="num">${fmt(mc.ath)} / ${fmt(mc.atl)}</b></div>
|
| 484 |
-
<div class="changes">
|
| 485 |
-
${["1D", "1W", "1M", "3M"].map((k) =>
|
| 486 |
-
`<span class="chip ${cls(ch[k])}">${k} ${arrow(ch[k])} ${fmt(ch[k])}%</span>`
|
| 487 |
-
).join("")}
|
| 488 |
-
</div>
|
| 489 |
-
<div class="volchips">
|
| 490 |
-
${(c.volume_changes || []).map((v, i) =>
|
| 491 |
-
`<span class="chip ${cls(v)}" title="Volume change D-${i + 1}">V${i + 1} ${fmt(v)}%</span>`
|
| 492 |
-
).join("")}
|
| 493 |
-
</div>
|
| 494 |
-
<canvas></canvas>
|
| 495 |
-
${c.live ? `<div class="live-row">
|
| 496 |
-
LIVE <b class="num ${cls(c.live.pct_change)}">${fmt(c.live.price)}
|
| 497 |
-
(${arrow(c.live.pct_change)} ${fmt(c.live.pct_change)}%)</b>
|
| 498 |
-
H ${fmt(c.live.day_high)} L ${fmt(c.live.day_low)}
|
| 499 |
-
Vol ${arrow(c.live.vol_change)} ${fmt(c.live.vol_change)}%
|
| 500 |
-
<div class="muted">${esc(c.live.updated_at)}</div>
|
| 501 |
-
</div>` : ""}
|
| 502 |
-
<div class="inputs">
|
| 503 |
-
<label>Quantity
|
| 504 |
-
<input type="number" class="in-qty" value="${c.quantity}" step="any"></label>
|
| 505 |
-
<label>Price
|
| 506 |
-
<input type="number" class="in-price" value="${c.price}" step="any"></label>
|
| 507 |
-
</div>
|
| 508 |
-
<div class="pl-row num">
|
| 509 |
-
Cost <b>${fmt(c.quantity * c.price)}</b> ·
|
| 510 |
-
CMP <b>${fmt(c.quantity * c.current_cmp)}</b> ·
|
| 511 |
-
P&L <b class="${cls(pl)}">${fmt(pl)} (${fmt(plPct)}%)</b>
|
| 512 |
-
</div>
|
| 513 |
-
<div class="card-foot">
|
| 514 |
-
<label><input type="checkbox" class="sel" ${c.selected ? "checked" : ""}>
|
| 515 |
-
Select</label>
|
| 516 |
-
<span class="reco">${c.recommendation
|
| 517 |
-
? `${c.recommendation.action}
|
| 518 |
-
<span class="muted">(${fmt(c.recommendation.confidence)})</span>`
|
| 519 |
-
: ""}</span>
|
| 520 |
-
<div style="display: flex; gap: 4px;">
|
| 521 |
-
<button class="ai-btn" aria-label="Generate AI analysis for ${c.symbol}">AI ✦</button>
|
| 522 |
-
<button class="mc-btn">${mc.link ? "MC ↗" : "Set MC link"}</button>
|
| 523 |
-
</div>
|
| 524 |
-
</div>`;
|
| 525 |
-
|
| 526 |
-
card.querySelector(".rm").addEventListener("click", async () => {
|
| 527 |
-
await api("/stocks/" + encodeURIComponent(c.symbol), { method: "DELETE" });
|
| 528 |
-
loadBoard();
|
| 529 |
-
});
|
| 530 |
-
card.querySelector(".sel").addEventListener("change", (e) => {
|
| 531 |
-
api("/stocks/" + encodeURIComponent(c.symbol) + "/select", {
|
| 532 |
-
method: "POST",
|
| 533 |
-
body: JSON.stringify({ selected: e.target.checked }),
|
| 534 |
-
});
|
| 535 |
-
});
|
| 536 |
-
const pushInputs = async () => {
|
| 537 |
-
const r = await api("/stocks/" + encodeURIComponent(c.symbol) + "/inputs", {
|
| 538 |
-
method: "POST",
|
| 539 |
-
body: JSON.stringify({
|
| 540 |
-
quantity: card.querySelector(".in-qty").value || 0,
|
| 541 |
-
price: card.querySelector(".in-price").value || 0,
|
| 542 |
-
}),
|
| 543 |
-
});
|
| 544 |
-
if (r.ok) { renderPortfolio(r.portfolio); loadBoard(); }
|
| 545 |
-
};
|
| 546 |
-
card.querySelectorAll(".in-qty, .in-price").forEach((inp) => {
|
| 547 |
-
inp.addEventListener("change", pushInputs);
|
| 548 |
-
});
|
| 549 |
-
card.querySelector(".ai-btn").addEventListener("click", () => {
|
| 550 |
-
openAIAnalysisModal(c.symbol, false);
|
| 551 |
-
});
|
| 552 |
-
|
| 553 |
-
card.querySelector(".mc-btn").addEventListener("click", async () => {
|
| 554 |
-
if (mc.link) { window.open(mc.link, "_blank"); return; }
|
| 555 |
-
const link = prompt(
|
| 556 |
-
`Enter the Moneycontrol URL for ${c.symbol} (${c.security || ""}):`);
|
| 557 |
-
if (link === null) return;
|
| 558 |
-
const r = await api("/mc-link", {
|
| 559 |
-
method: "POST",
|
| 560 |
-
body: JSON.stringify({ symbol: c.symbol, security: c.security, link }),
|
| 561 |
-
});
|
| 562 |
-
if (r.ok) { toast(r.message, "ok"); loadBoard(); }
|
| 563 |
-
});
|
| 564 |
-
|
| 565 |
-
if (c.chart) drawChart(card.querySelector("canvas"), c);
|
| 566 |
-
return card;
|
| 567 |
-
}
|
| 568 |
-
|
| 569 |
-
function drawChart(canvas, c) {
|
| 570 |
-
const ch = c.chart;
|
| 571 |
-
const datasets = [{
|
| 572 |
-
label: "Close", data: ch.prices, borderColor: "#3b82c4",
|
| 573 |
-
borderWidth: 1.6, pointRadius: 0, tension: 0.1, yAxisID: "y",
|
| 574 |
-
}];
|
| 575 |
-
const dmaColors = { 9: "#8e44ad", 20: "#d29922", 50: "#16a085", 200: "#e07b70" };
|
| 576 |
-
for (const w of [9, 20, 50, 200]) {
|
| 577 |
-
const d = ch["dma_" + w];
|
| 578 |
-
if (d) datasets.push({
|
| 579 |
-
label: w + "DMA", data: d, borderColor: dmaColors[w],
|
| 580 |
-
borderWidth: 1, pointRadius: 0, tension: 0.1, yAxisID: "y",
|
| 581 |
-
});
|
| 582 |
-
}
|
| 583 |
-
if (ch.volumes && ch.volumes.length) {
|
| 584 |
-
datasets.push({
|
| 585 |
-
label: "Volume", data: ch.volumes, type: "bar",
|
| 586 |
-
backgroundColor: "rgba(125,136,148,.35)", yAxisID: "vol",
|
| 587 |
-
});
|
| 588 |
-
}
|
| 589 |
-
state.charts[c.symbol] = new Chart(canvas, {
|
| 590 |
-
type: "line",
|
| 591 |
-
data: { labels: ch.dates, datasets },
|
| 592 |
-
options: {
|
| 593 |
-
responsive: true, maintainAspectRatio: true, aspectRatio: 2.2, animation: false,
|
| 594 |
-
plugins: { legend: { display: false } },
|
| 595 |
-
scales: {
|
| 596 |
-
x: { ticks: { maxTicksLimit: 5, color: "#7d8894" }, grid: { display: false } },
|
| 597 |
-
y: { ticks: { color: "#7d8894" }, grid: { color: "#2a333f" } },
|
| 598 |
-
vol: { position: "right", display: false },
|
| 599 |
-
},
|
| 600 |
-
},
|
| 601 |
-
});
|
| 602 |
-
}
|
| 603 |
-
|
| 604 |
-
/* --------------------------------------------------------------- actions */
|
| 605 |
-
|
| 606 |
-
async function loadWatchlist(n) {
|
| 607 |
-
const r = await api("/watchlists/load/" + n, { method: "POST" });
|
| 608 |
-
if (r.ok) toast(`Loaded ${r.loaded} stocks from '${r.name}'.`, "ok");
|
| 609 |
-
loadBoard();
|
| 610 |
-
}
|
| 611 |
-
|
| 612 |
-
function pickWatchlistFile() {
|
| 613 |
-
const inp = document.createElement("input");
|
| 614 |
-
inp.type = "file";
|
| 615 |
-
inp.accept = ".json";
|
| 616 |
-
inp.addEventListener("change", async () => {
|
| 617 |
-
if (!inp.files.length) return;
|
| 618 |
-
const fd = new FormData();
|
| 619 |
-
fd.append("file", inp.files[0]);
|
| 620 |
-
busy("Loading watchlist file…");
|
| 621 |
-
const res = await fetch("/api/watchlists/load-file",
|
| 622 |
-
{ method: "POST", body: fd });
|
| 623 |
-
const body = await res.json();
|
| 624 |
-
unbusy();
|
| 625 |
-
toast(body.message || "Done", body.ok ? "ok" : "err");
|
| 626 |
-
loadBoard();
|
| 627 |
-
});
|
| 628 |
-
inp.click();
|
| 629 |
-
}
|
| 630 |
-
|
| 631 |
-
async function loadKnownFile(name) {
|
| 632 |
-
busy("Loading live analysis file…");
|
| 633 |
-
try {
|
| 634 |
-
const body = await api("/watchlists/load-file", {
|
| 635 |
-
method: "POST",
|
| 636 |
-
body: JSON.stringify({ known: name }),
|
| 637 |
-
});
|
| 638 |
-
unbusy();
|
| 639 |
-
if (body) {
|
| 640 |
-
toast(body.message || "Done", body.ok ? "ok" : "err");
|
| 641 |
-
}
|
| 642 |
-
loadBoard();
|
| 643 |
-
} catch (e) {
|
| 644 |
-
unbusy();
|
| 645 |
-
toast(String(e), "err");
|
| 646 |
-
}
|
| 647 |
-
}
|
| 648 |
-
|
| 649 |
-
async function priceAnalysis(duration, changeType) {
|
| 650 |
-
const t = prompt(
|
| 651 |
-
`Enter the minimum % change threshold for ${duration} ${changeType}:`,
|
| 652 |
-
changeType === "Max" ? "5" : "-5");
|
| 653 |
-
if (t === null) return;
|
| 654 |
-
const r = await runJob(api("/analysis/price", {
|
| 655 |
-
method: "POST",
|
| 656 |
-
body: JSON.stringify({ duration, change_type: changeType, threshold: t }),
|
| 657 |
-
}), `Calculating ${duration} ${changeType} price changes…`);
|
| 658 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 659 |
-
loadBoard();
|
| 660 |
-
}
|
| 661 |
-
|
| 662 |
-
async function volumeAnalysis(duration) {
|
| 663 |
-
const r = await runJob(api("/analysis/volume", {
|
| 664 |
-
method: "POST", body: JSON.stringify({ duration }),
|
| 665 |
-
}), `Calculating ${duration} volume movers…`);
|
| 666 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 667 |
-
loadBoard();
|
| 668 |
-
}
|
| 669 |
-
|
| 670 |
-
async function returnAnalysis(duration) {
|
| 671 |
-
const r = await runJob(api("/analysis/return", {
|
| 672 |
-
method: "POST", body: JSON.stringify({ duration }),
|
| 673 |
-
}), `Calculating ${duration} max returns…`);
|
| 674 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 675 |
-
loadBoard();
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
async function topScore() {
|
| 679 |
-
const r = await runJob(api("/analysis/top-score", { method: "POST" }),
|
| 680 |
-
"Loading top Moneycontrol scores…");
|
| 681 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 682 |
-
loadBoard();
|
| 683 |
-
}
|
| 684 |
-
|
| 685 |
-
async function recommend() {
|
| 686 |
-
const r = await runJob(api("/recommend", { method: "POST" }),
|
| 687 |
-
"Running buy/sell recommender…");
|
| 688 |
-
if (r.recommendations) {
|
| 689 |
-
const top = r.recommendations.slice(0, 10)
|
| 690 |
-
.map((x) => `${x.symbol}: ${x.action} (${fmt(x.confidence)})`)
|
| 691 |
-
.join("\n");
|
| 692 |
-
toast("Recommendations updated.\n" + top, "ok");
|
| 693 |
-
}
|
| 694 |
-
loadBoard();
|
| 695 |
-
}
|
| 696 |
-
|
| 697 |
-
async function trainModels() {
|
| 698 |
-
if (!confirm("Train LSTM models for ALL symbols? This can take a long time."))
|
| 699 |
-
return;
|
| 700 |
-
const r = await runJob(api("/train", { method: "POST" }),
|
| 701 |
-
"Training LSTM models…");
|
| 702 |
-
if (r.ok) toast(`Training complete for ${r.trained_symbols} symbols.`, "ok");
|
| 703 |
-
loadBoard();
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
async function setVolumeCutoff() {
|
| 707 |
-
const v = prompt("Minimum average volume for volume analysis:",
|
| 708 |
-
state.board ? state.board.volume_cutoff : 10000);
|
| 709 |
-
if (v === null) return;
|
| 710 |
-
const r = await api("/config", {
|
| 711 |
-
method: "POST", body: JSON.stringify({ volume_cutoff: v }),
|
| 712 |
-
});
|
| 713 |
-
if (r.ok) { toast(`Volume cutoff set to ${fmt(r.volume_cutoff, 0)}.`, "ok"); loadBoard(); }
|
| 714 |
-
}
|
| 715 |
-
|
| 716 |
-
/* Scripts terminal */
|
| 717 |
-
let termRun = null, termTimer = null, termLast = -1;
|
| 718 |
-
|
| 719 |
-
async function startScript(s) {
|
| 720 |
-
const r = await api("/scripts/start", {
|
| 721 |
-
method: "POST", body: JSON.stringify({ file: s.file }),
|
| 722 |
-
});
|
| 723 |
-
if (!r.ok) return;
|
| 724 |
-
termRun = r.run_id;
|
| 725 |
-
termLast = -1;
|
| 726 |
-
$("#terminal-title").textContent = s.label + " — " + s.file;
|
| 727 |
-
$("#terminal-out").innerHTML = "";
|
| 728 |
-
$("#modal-terminal").classList.remove("hidden");
|
| 729 |
-
termTimer = setInterval(pollTerminal, 800);
|
| 730 |
-
}
|
| 731 |
-
|
| 732 |
-
async function pollTerminal() {
|
| 733 |
-
if (!termRun) return;
|
| 734 |
-
const r = await api(`/scripts/${termRun}/output?after=${termLast}`);
|
| 735 |
-
if (!r.ok) return;
|
| 736 |
-
const out = $("#terminal-out");
|
| 737 |
-
for (const ln of r.lines) {
|
| 738 |
-
termLast = ln.n;
|
| 739 |
-
const span = document.createElement("span");
|
| 740 |
-
span.className = ln.tag;
|
| 741 |
-
span.textContent = ln.text + "\n";
|
| 742 |
-
out.appendChild(span);
|
| 743 |
-
}
|
| 744 |
-
if (r.lines.length) out.scrollTop = out.scrollHeight;
|
| 745 |
-
if (r.finished && termTimer) { clearInterval(termTimer); termTimer = null; }
|
| 746 |
-
}
|
| 747 |
-
|
| 748 |
-
$("#terminal-send").addEventListener("click", sendTerminal);
|
| 749 |
-
$("#terminal-in").addEventListener("keydown", (e) => {
|
| 750 |
-
if (e.key === "Enter") sendTerminal();
|
| 751 |
-
});
|
| 752 |
-
async function sendTerminal() {
|
| 753 |
-
const text = $("#terminal-in").value;
|
| 754 |
-
if (!text.trim() || !termRun) return;
|
| 755 |
-
await api(`/scripts/${termRun}/input`, {
|
| 756 |
-
method: "POST", body: JSON.stringify({ text }),
|
| 757 |
-
});
|
| 758 |
-
$("#terminal-in").value = "";
|
| 759 |
-
}
|
| 760 |
-
$("#terminal-close").addEventListener("click", () => {
|
| 761 |
-
$("#modal-terminal").classList.add("hidden");
|
| 762 |
-
if (termTimer) clearInterval(termTimer);
|
| 763 |
-
termTimer = null;
|
| 764 |
-
});
|
| 765 |
-
|
| 766 |
-
/* ------------------------------------------------------------- toolbar */
|
| 767 |
-
|
| 768 |
-
$("#btn-add").addEventListener("click", async () => {
|
| 769 |
-
const key = $("#stock-search").value.trim();
|
| 770 |
-
if (!key) return;
|
| 771 |
-
const r = await api("/stocks", {
|
| 772 |
-
method: "POST", body: JSON.stringify({ key }),
|
| 773 |
-
});
|
| 774 |
-
if (r.ok) { $("#stock-search").value = ""; loadBoard(); }
|
| 775 |
-
});
|
| 776 |
-
$("#stock-search").addEventListener("keydown", (e) => {
|
| 777 |
-
if (e.key === "Enter") $("#btn-add").click();
|
| 778 |
-
});
|
| 779 |
-
|
| 780 |
-
$("#btn-sensex").addEventListener("click", async () => {
|
| 781 |
-
await api("/index/SENSEX", { method: "POST" }); loadBoard();
|
| 782 |
-
});
|
| 783 |
-
$("#btn-nifty").addEventListener("click", async () => {
|
| 784 |
-
await api("/index/NIFTY", { method: "POST" }); loadBoard();
|
| 785 |
-
});
|
| 786 |
-
|
| 787 |
-
$("#btn-portfolio").addEventListener("click", () => {
|
| 788 |
-
const inp = document.createElement("input");
|
| 789 |
-
inp.type = "file";
|
| 790 |
-
inp.accept = ".csv";
|
| 791 |
-
inp.addEventListener("change", async () => {
|
| 792 |
-
if (!inp.files.length) return;
|
| 793 |
-
const fd = new FormData();
|
| 794 |
-
fd.append("file", inp.files[0]);
|
| 795 |
-
const token = localStorage.getItem("session_token") || "";
|
| 796 |
-
const startPromise = fetch("/api/portfolio/import", {
|
| 797 |
-
method: "POST",
|
| 798 |
-
headers: { "X-Session-Token": token },
|
| 799 |
-
body: fd
|
| 800 |
-
}).then(res => res.json());
|
| 801 |
-
|
| 802 |
-
const r = await runJob(startPromise, "Importing portfolio…");
|
| 803 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 804 |
-
loadBoard();
|
| 805 |
-
});
|
| 806 |
-
inp.click();
|
| 807 |
-
});
|
| 808 |
-
|
| 809 |
-
$("#btn-export").addEventListener("click", async () => {
|
| 810 |
-
const r = await api("/export", { method: "POST" });
|
| 811 |
-
if (r.ok) {
|
| 812 |
-
toast(r.message, "ok");
|
| 813 |
-
window.location = "/api/export/download?file=" + encodeURIComponent(r.file);
|
| 814 |
-
}
|
| 815 |
-
});
|
| 816 |
-
|
| 817 |
-
$("#date-input").addEventListener("change", async (e) => {
|
| 818 |
-
const r = await api("/date", {
|
| 819 |
-
method: "POST", body: JSON.stringify({ date: e.target.value }),
|
| 820 |
-
});
|
| 821 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 822 |
-
loadBoard();
|
| 823 |
-
});
|
| 824 |
-
|
| 825 |
-
/* Live toggle + modal */
|
| 826 |
-
$("#live-toggle").addEventListener("change", (e) => {
|
| 827 |
-
if (e.target.checked) {
|
| 828 |
-
$("#modal-live").classList.remove("hidden");
|
| 829 |
-
} else {
|
| 830 |
-
stopLive();
|
| 831 |
-
}
|
| 832 |
-
});
|
| 833 |
-
$("#live-cancel").addEventListener("click", () => {
|
| 834 |
-
$("#modal-live").classList.add("hidden");
|
| 835 |
-
$("#live-toggle").checked = false;
|
| 836 |
-
});
|
| 837 |
-
async function fetchLiveDataInBackground() {
|
| 838 |
-
toast("Fetching live data in background…", "info");
|
| 839 |
-
try {
|
| 840 |
-
const start = await api("/live/refresh", { method: "POST" });
|
| 841 |
-
if (!start || !start.job_id) {
|
| 842 |
-
if (start && start.message) toast(start.message, "err");
|
| 843 |
-
return;
|
| 844 |
-
}
|
| 845 |
-
(async () => {
|
| 846 |
-
const startTime = Date.now();
|
| 847 |
-
while (Date.now() - startTime < 60000) {
|
| 848 |
-
await new Promise((r) => setTimeout(r, 1000));
|
| 849 |
-
const job = await api("/jobs/" + start.job_id);
|
| 850 |
-
if (job.status === "done") {
|
| 851 |
-
const res = job.result || {};
|
| 852 |
-
toast(res.message || `Live data updated (${res.updated || 0} stocks).`, "ok");
|
| 853 |
-
loadBoard();
|
| 854 |
-
break;
|
| 855 |
-
}
|
| 856 |
-
if (job.status === "error") {
|
| 857 |
-
toast(job.error || "Live data fetch failed.", "err");
|
| 858 |
-
break;
|
| 859 |
-
}
|
| 860 |
-
}
|
| 861 |
-
})();
|
| 862 |
-
} catch (e) {
|
| 863 |
-
toast(String(e), "err");
|
| 864 |
-
}
|
| 865 |
-
}
|
| 866 |
-
|
| 867 |
-
$("#live-ok").addEventListener("click", async () => {
|
| 868 |
-
$("#modal-live").classList.add("hidden");
|
| 869 |
-
const r = await api("/live/start", {
|
| 870 |
-
method: "POST",
|
| 871 |
-
body: JSON.stringify({
|
| 872 |
-
source: $("#live-source").value,
|
| 873 |
-
refresh_rate: $("#live-rate").value,
|
| 874 |
-
}),
|
| 875 |
-
});
|
| 876 |
-
if (!r.ok) { $("#live-toggle").checked = false; return; }
|
| 877 |
-
if (r.warning) toast(r.warning);
|
| 878 |
-
fetchLiveDataInBackground();
|
| 879 |
-
// Poll the board while live so scheduler updates reach the UI.
|
| 880 |
-
if (state.livePoll) clearInterval(state.livePoll);
|
| 881 |
-
state.livePoll = setInterval(loadBoard, 10000);
|
| 882 |
-
});
|
| 883 |
-
|
| 884 |
-
async function stopLive() {
|
| 885 |
-
if (state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
| 886 |
-
await api("/live/stop", { method: "POST" });
|
| 887 |
-
loadBoard();
|
| 888 |
-
}
|
| 889 |
-
|
| 890 |
-
/* Bottom controls */
|
| 891 |
-
$("#btn-refresh").addEventListener("click", async () => {
|
| 892 |
-
const r = await runJob(api("/refresh", { method: "POST" }),
|
| 893 |
-
"Refreshing dashboard data…");
|
| 894 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 895 |
-
loadBoard();
|
| 896 |
-
});
|
| 897 |
-
|
| 898 |
-
$("#btn-save").addEventListener("click", async () => {
|
| 899 |
-
const r = await api("/watchlists/save", { method: "POST" });
|
| 900 |
-
if (r.needs_save_as) { openSaveAs(); return; }
|
| 901 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 902 |
-
loadBoard();
|
| 903 |
-
});
|
| 904 |
-
|
| 905 |
-
function openSaveAs() {
|
| 906 |
-
$("#saveas-name").value = state.board
|
| 907 |
-
? state.board.banner.watchlist.replace(/\s*\*$/, "") : "";
|
| 908 |
-
$("#modal-saveas").classList.remove("hidden");
|
| 909 |
-
}
|
| 910 |
-
$("#btn-saveas").addEventListener("click", openSaveAs);
|
| 911 |
-
$("#saveas-cancel").addEventListener("click", () =>
|
| 912 |
-
$("#modal-saveas").classList.add("hidden"));
|
| 913 |
-
$("#saveas-ok").addEventListener("click", async () => {
|
| 914 |
-
$("#modal-saveas").classList.add("hidden");
|
| 915 |
-
const r = await api("/watchlists/save-as", {
|
| 916 |
-
method: "POST",
|
| 917 |
-
body: JSON.stringify({
|
| 918 |
-
slot: Number($("#saveas-slot").value),
|
| 919 |
-
name: $("#saveas-name").value,
|
| 920 |
-
}),
|
| 921 |
-
});
|
| 922 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 923 |
-
loadBoard();
|
| 924 |
-
});
|
| 925 |
-
|
| 926 |
-
$("#btn-add-selected").addEventListener("click", async () => {
|
| 927 |
-
const slot = prompt("Add selected stocks to watchlist slot (1–20):", "1");
|
| 928 |
-
if (slot === null) return;
|
| 929 |
-
const r = await api("/watchlists/add-selected", {
|
| 930 |
-
method: "POST", body: JSON.stringify({ slot: Number(slot) }),
|
| 931 |
-
});
|
| 932 |
-
if (r.message) toast(r.message, r.ok ? "ok" : "err");
|
| 933 |
-
loadBoard();
|
| 934 |
-
});
|
| 935 |
-
|
| 936 |
-
$("#btn-delete-all").addEventListener("click", async () => {
|
| 937 |
-
if (!confirm("Remove all stocks from the dashboard?")) return;
|
| 938 |
-
await api("/stocks", { method: "DELETE" });
|
| 939 |
-
loadBoard();
|
| 940 |
-
});
|
| 941 |
-
|
| 942 |
-
function showShutdownScreen() {
|
| 943 |
-
if (state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
| 944 |
-
if (state.heartbeat) { clearInterval(state.heartbeat); state.heartbeat = null; }
|
| 945 |
-
const screen = $("#shutdown-screen");
|
| 946 |
-
if (screen) {
|
| 947 |
-
screen.classList.add("visible");
|
| 948 |
-
}
|
| 949 |
-
}
|
| 950 |
-
|
| 951 |
-
function updateEnvPill(isHf) {
|
| 952 |
-
const pill = $("#env-pill");
|
| 953 |
-
if (!pill) return;
|
| 954 |
-
if (isHf) {
|
| 955 |
-
pill.textContent = "HuggingFace Spaces";
|
| 956 |
-
pill.className = "env-pill hf";
|
| 957 |
-
} else {
|
| 958 |
-
pill.textContent = "Local";
|
| 959 |
-
pill.className = "env-pill local";
|
| 960 |
-
}
|
| 961 |
-
}
|
| 962 |
-
|
| 963 |
-
function startHeartbeat() {
|
| 964 |
-
state.consecutiveFailures = 0;
|
| 965 |
-
state.heartbeat = setInterval(async () => {
|
| 966 |
-
try {
|
| 967 |
-
const res = await fetch("/api/status");
|
| 968 |
-
if (!res.ok) {
|
| 969 |
-
state.consecutiveFailures++;
|
| 970 |
-
if (state.consecutiveFailures >= 3) {
|
| 971 |
-
showShutdownScreen();
|
| 972 |
-
}
|
| 973 |
-
return;
|
| 974 |
-
}
|
| 975 |
-
const body = await res.json().catch(() => ({}));
|
| 976 |
-
if (body.exiting) {
|
| 977 |
-
showShutdownScreen();
|
| 978 |
-
}
|
| 979 |
-
updateEnvPill(body.is_hf);
|
| 980 |
-
state.consecutiveFailures = 0;
|
| 981 |
-
} catch (err) {
|
| 982 |
-
state.consecutiveFailures++;
|
| 983 |
-
if (state.consecutiveFailures >= 3) {
|
| 984 |
-
showShutdownScreen();
|
| 985 |
-
}
|
| 986 |
-
}
|
| 987 |
-
}, 1000);
|
| 988 |
-
}
|
| 989 |
-
|
| 990 |
-
async function triggerExit() {
|
| 991 |
-
if (!confirm("Are you sure you want to exit? This will shut down the server and stop the application.")) {
|
| 992 |
-
return;
|
| 993 |
-
}
|
| 994 |
-
showShutdownScreen();
|
| 995 |
-
try {
|
| 996 |
-
await fetch("/api/shutdown", { method: "POST" });
|
| 997 |
-
} catch (err) {
|
| 998 |
-
console.error("Shutdown initiated (fetch failed/aborted as server terminated).", err);
|
| 999 |
-
}
|
| 1000 |
-
}
|
| 1001 |
-
|
| 1002 |
-
/* Login, TOTP, and Admin Control Panel helper functions */
|
| 1003 |
-
|
| 1004 |
-
function showLoginBox() {
|
| 1005 |
-
const splash = $("#splash-screen");
|
| 1006 |
-
if (splash) {
|
| 1007 |
-
splash.classList.remove("hidden");
|
| 1008 |
-
}
|
| 1009 |
-
const splashStatus = $("#splash-status");
|
| 1010 |
-
const loader = $(".splash-loader-container");
|
| 1011 |
-
const loginBox = $("#login-box");
|
| 1012 |
-
const totpBox = $("#totp-box");
|
| 1013 |
-
|
| 1014 |
-
if (loader) loader.classList.add("hidden");
|
| 1015 |
-
if (splashStatus) splashStatus.classList.add("hidden");
|
| 1016 |
-
if (totpBox) totpBox.classList.add("hidden");
|
| 1017 |
-
if (loginBox) loginBox.classList.remove("hidden");
|
| 1018 |
-
}
|
| 1019 |
-
|
| 1020 |
-
function showTotpBox(userid, qrUrl = null, secretKey = null) {
|
| 1021 |
-
const loginBox = $("#login-box");
|
| 1022 |
-
const totpBox = $("#totp-box");
|
| 1023 |
-
const qrContainer = $("#totp-qr-container");
|
| 1024 |
-
const qrImage = $("#totp-qr-image");
|
| 1025 |
-
const secretKeyText = $("#totp-secret-key");
|
| 1026 |
-
|
| 1027 |
-
if (loginBox) loginBox.classList.add("hidden");
|
| 1028 |
-
if (totpBox) totpBox.classList.remove("hidden");
|
| 1029 |
-
|
| 1030 |
-
if (secretKey && secretKeyText) {
|
| 1031 |
-
secretKeyText.textContent = secretKey;
|
| 1032 |
-
if (qrContainer) qrContainer.classList.remove("hidden");
|
| 1033 |
-
|
| 1034 |
-
if (qrUrl && qrImage) {
|
| 1035 |
-
qrImage.src = qrUrl;
|
| 1036 |
-
qrImage.style.display = "block";
|
| 1037 |
-
} else if (qrImage) {
|
| 1038 |
-
qrImage.style.display = "none";
|
| 1039 |
-
}
|
| 1040 |
-
} else if (qrContainer) {
|
| 1041 |
-
qrContainer.classList.add("hidden");
|
| 1042 |
-
}
|
| 1043 |
-
}
|
| 1044 |
-
|
| 1045 |
-
let currentAdminActionUser = null;
|
| 1046 |
-
|
| 1047 |
-
async function openAdminPanel() {
|
| 1048 |
-
$("#modal-admin").classList.remove("hidden");
|
| 1049 |
-
await refreshAdminDashboard();
|
| 1050 |
-
}
|
| 1051 |
-
|
| 1052 |
-
async function refreshAdminDashboard() {
|
| 1053 |
-
const res = await api("/admin/dashboard");
|
| 1054 |
-
if (!res.ok) return;
|
| 1055 |
-
|
| 1056 |
-
$("#stat-users").textContent = res.total_users;
|
| 1057 |
-
$("#stat-symbols").textContent = res.total_symbols;
|
| 1058 |
-
$("#stat-yfinance").textContent = res.yfinance_connected ? "Online" : "Offline";
|
| 1059 |
-
$("#stat-yfinance").style.color = res.yfinance_connected ? "var(--gain)" : "var(--loss)";
|
| 1060 |
-
|
| 1061 |
-
const userList = $("#admin-user-list");
|
| 1062 |
-
userList.innerHTML = "";
|
| 1063 |
-
|
| 1064 |
-
Object.values(res.users).forEach((user) => {
|
| 1065 |
-
const isOnline = user.login_status === "online";
|
| 1066 |
-
const isActive = user.active;
|
| 1067 |
-
const isUserAdmin = user.is_admin;
|
| 1068 |
-
|
| 1069 |
-
const tr = document.createElement("tr");
|
| 1070 |
-
tr.style.borderBottom = "1px solid rgba(255, 255, 255, 0.08)";
|
| 1071 |
-
tr.innerHTML = `
|
| 1072 |
-
<td style="padding: 6px; font-weight: bold; color: #fff;">${esc(user.userid)}</td>
|
| 1073 |
-
<td style="padding: 6px;"><span class="chip" style="background: ${isOnline ? "rgba(39, 174, 96, 0.2)" : "rgba(127, 140, 141, 0.2)"}; border-color: ${isOnline ? "var(--gain)" : "var(--border)"}; color: ${isOnline ? "var(--gain)" : "var(--muted)"}; padding: 1px 6px; font-size: 10px;">${isOnline ? "Online" : "Offline"}</span></td>
|
| 1074 |
-
<td style="padding: 6px; color: ${isUserAdmin ? "#a78bfa" : "#94a3b8"};">${isUserAdmin ? "Admin" : "User"}</td>
|
| 1075 |
-
<td style="padding: 6px;">${user.login_count}</td>
|
| 1076 |
-
<td style="padding: 6px; color: #64748b; font-size: 11px;">${user.last_login ? user.last_login.substring(0, 16).replace("T", " ") : "Never"}</td>
|
| 1077 |
-
<td style="padding: 6px;"><span class="chip" style="background: ${isActive ? "rgba(39, 174, 96, 0.1)" : "rgba(192, 57, 43, 0.15)"}; color: ${isActive ? "var(--gain)" : "var(--loss)"}; border-color: ${isActive ? "rgba(39, 174, 96, 0.3)" : "rgba(192, 57, 43, 0.3)"}; padding: 1px 6px; font-size: 10px;">${isActive ? "Active" : "Locked"}</span></td>
|
| 1078 |
-
<td style="padding: 6px; text-align: right; white-space: nowrap;">
|
| 1079 |
-
<button class="btn-action-qr primary btn-xs" title="Show Authenticator QR" style="padding: 2px 6px; font-size: 11px; margin-right: 3px;">📱 QR</button>
|
| 1080 |
-
<button class="btn-action-metrics primary btn-xs" title="View Metrics" style="padding: 2px 6px; font-size: 11px; margin-right: 3px;">📊 Stats</button>
|
| 1081 |
-
<button class="btn-action-reset primary btn-xs" title="Full Account Reset" style="padding: 2px 6px; font-size: 11px; margin-right: 3px; background: var(--loss); border-color: var(--loss);">🔄 Full Reset</button>
|
| 1082 |
-
<button class="btn-action-lock primary btn-xs" title="Lock/Unlock User" style="padding: 2px 6px; font-size: 11px; margin-right: 3px; background: ${isActive ? "var(--loss)" : "var(--gain)"}; border-color: ${isActive ? "var(--loss)" : "var(--gain)"};">${isActive ? "🔒 Lock" : "🔓 Unlock"}</button>
|
| 1083 |
-
<button class="btn-action-mirror primary btn-xs" title="Mirror Activity Logs" style="padding: 2px 6px; font-size: 11px; margin-right: 3px;">🔍 Logs</button>
|
| 1084 |
-
<button class="btn-action-chat primary btn-xs" style="padding: 2px 6px; font-size: 11px; margin-right: 3px;">💬 Chat</button>
|
| 1085 |
-
<button class="btn-action-delete danger btn-xs" title="Delete User" style="padding: 2px 6px; font-size: 11px;">🗑️ Delete</button>
|
| 1086 |
-
</td>
|
| 1087 |
-
`;
|
| 1088 |
-
|
| 1089 |
-
// Wire actions
|
| 1090 |
-
tr.querySelector(".btn-action-qr").onclick = () => runAdminAction("qr", user.userid);
|
| 1091 |
-
tr.querySelector(".btn-action-metrics").onclick = () => runAdminAction("metrics", user.userid);
|
| 1092 |
-
tr.querySelector(".btn-action-reset").onclick = () => {
|
| 1093 |
-
if (confirm(`Are you sure you want to perform a full account reset (password + TOTP) for ${user.userid}? This will log them out immediately.`)) {
|
| 1094 |
-
runAdminAction("reset", user.userid);
|
| 1095 |
-
}
|
| 1096 |
-
};
|
| 1097 |
-
tr.querySelector(".btn-action-lock").onclick = () => runAdminAction("lock", user.userid);
|
| 1098 |
-
tr.querySelector(".btn-action-mirror").onclick = () => runAdminAction("mirror", user.userid);
|
| 1099 |
-
tr.querySelector(".btn-action-chat").onclick = () => runAdminAction("chat", user.userid);
|
| 1100 |
-
tr.querySelector(".btn-action-delete").onclick = () => {
|
| 1101 |
-
if (confirm(`Are you sure you want to PERMANENTLY delete user ${user.userid}?`)) {
|
| 1102 |
-
runAdminAction("delete", user.userid);
|
| 1103 |
-
}
|
| 1104 |
-
};
|
| 1105 |
-
|
| 1106 |
-
userList.appendChild(tr);
|
| 1107 |
-
});
|
| 1108 |
-
}
|
| 1109 |
-
|
| 1110 |
-
function hideAllAdminSidebarPanels() {
|
| 1111 |
-
document.querySelectorAll(".sidebar-panel").forEach((p) => p.classList.add("hidden"));
|
| 1112 |
-
const grid = document.querySelector(".admin-main-grid");
|
| 1113 |
-
if (grid) grid.classList.remove("has-sidebar");
|
| 1114 |
-
}
|
| 1115 |
-
|
| 1116 |
-
function showAdminSidebarPanel(panelId) {
|
| 1117 |
-
hideAllAdminSidebarPanels();
|
| 1118 |
-
const p = document.getElementById(panelId);
|
| 1119 |
-
if (p) p.classList.remove("hidden");
|
| 1120 |
-
const grid = document.querySelector(".admin-main-grid");
|
| 1121 |
-
if (grid) grid.classList.add("has-sidebar");
|
| 1122 |
-
}
|
| 1123 |
-
|
| 1124 |
-
async function runAdminAction(action, target_uid) {
|
| 1125 |
-
currentAdminActionUser = target_uid;
|
| 1126 |
-
hideAllAdminSidebarPanels();
|
| 1127 |
-
|
| 1128 |
-
if (action === "qr") {
|
| 1129 |
-
const res = await api("/admin/action", {
|
| 1130 |
-
method: "POST",
|
| 1131 |
-
body: JSON.stringify({ action, target_uid })
|
| 1132 |
-
});
|
| 1133 |
-
if (res.ok) {
|
| 1134 |
-
if (res.qr) {
|
| 1135 |
-
$("#admin-qr-display").innerHTML = `<img src="${res.qr}" style="max-width: 140px;"/>`;
|
| 1136 |
-
} else {
|
| 1137 |
-
$("#admin-qr-display").innerHTML = `<div style="padding: 10px; color: #94a3b8; font-size: 12px; border: 1px dashed #475569; border-radius: 4px; text-align: center;">Pending first login by user.<br/>(QR code will be provisioned then)</div>`;
|
| 1138 |
-
}
|
| 1139 |
-
$("#admin-secret-key").textContent = res.secret;
|
| 1140 |
-
showAdminSidebarPanel("panel-qr");
|
| 1141 |
-
}
|
| 1142 |
-
} else if (action === "metrics") {
|
| 1143 |
-
const res = await api("/admin/action", {
|
| 1144 |
-
method: "POST",
|
| 1145 |
-
body: JSON.stringify({ action, target_uid })
|
| 1146 |
-
});
|
| 1147 |
-
if (res.ok) {
|
| 1148 |
-
$("#admin-metrics-display").innerHTML = `
|
| 1149 |
-
<div style="margin-bottom: 4px;"><strong>User ID:</strong> ${res.userid}</div>
|
| 1150 |
-
<div style="margin-bottom: 4px;"><strong>Login Count:</strong> ${res.login_count}</div>
|
| 1151 |
-
<div style="margin-bottom: 4px;"><strong>Last Login:</strong> ${res.last_login ? res.last_login.substring(0, 16).replace("T", " ") : "Never"}</div>
|
| 1152 |
-
<div style="margin-bottom: 4px;"><strong>Symbols in State:</strong> ${res.symbols_count}</div>
|
| 1153 |
-
<div style="margin-bottom: 4px;"><strong>Watchlists Created:</strong> ${res.watchlists_count}</div>
|
| 1154 |
-
`;
|
| 1155 |
-
showAdminSidebarPanel("panel-metrics");
|
| 1156 |
-
}
|
| 1157 |
-
} else if (action === "reset" || action === "lock" || action === "delete") {
|
| 1158 |
-
const res = await api("/admin/action", {
|
| 1159 |
-
method: "POST",
|
| 1160 |
-
body: JSON.stringify({ action, target_uid })
|
| 1161 |
-
});
|
| 1162 |
-
if (res.ok) {
|
| 1163 |
-
toast(res.message, "ok");
|
| 1164 |
-
await refreshAdminDashboard();
|
| 1165 |
-
}
|
| 1166 |
-
} else if (action === "mirror") {
|
| 1167 |
-
const res = await api("/admin/action", {
|
| 1168 |
-
method: "POST",
|
| 1169 |
-
body: JSON.stringify({ action, target_uid })
|
| 1170 |
-
});
|
| 1171 |
-
if (res.ok) {
|
| 1172 |
-
const logs = res.logs || [];
|
| 1173 |
-
const logText = logs.map((l) => `[${l.timestamp.substring(11, 19)}] ${l.action.toUpperCase()}: ${l.status} (${l.details || ""})`).join("\n");
|
| 1174 |
-
$("#admin-mirror-display").textContent = logText || "No activities logged yet.";
|
| 1175 |
-
showAdminSidebarPanel("panel-mirror");
|
| 1176 |
-
}
|
| 1177 |
-
} else if (action === "chat") {
|
| 1178 |
-
const res = await api("/admin/action", {
|
| 1179 |
-
method: "POST",
|
| 1180 |
-
body: JSON.stringify({ action, target_uid })
|
| 1181 |
-
});
|
| 1182 |
-
if (res.ok) {
|
| 1183 |
-
$("#chat-user-title").textContent = `Chat with ${target_uid}`;
|
| 1184 |
-
const chatDisplay = $("#admin-chat-display");
|
| 1185 |
-
chatDisplay.innerHTML = "";
|
| 1186 |
-
res.messages.forEach((m) => {
|
| 1187 |
-
const div = document.createElement("div");
|
| 1188 |
-
div.className = "chat-msg " + m.sender;
|
| 1189 |
-
div.innerHTML = `<span>${esc(m.text)}</span>`;
|
| 1190 |
-
chatDisplay.appendChild(div);
|
| 1191 |
-
});
|
| 1192 |
-
showAdminSidebarPanel("panel-chat");
|
| 1193 |
-
}
|
| 1194 |
-
}
|
| 1195 |
-
}
|
| 1196 |
-
|
| 1197 |
-
/* boot */
|
| 1198 |
-
document.addEventListener("DOMContentLoaded", async () => {
|
| 1199 |
-
const btnLoginSubmit = $("#btn-login-submit");
|
| 1200 |
-
const btnTotpSubmit = $("#btn-totp-submit");
|
| 1201 |
-
const loginUsernameIn = $("#login-username");
|
| 1202 |
-
const loginPasswordIn = $("#login-password");
|
| 1203 |
-
const totpIn = $("#totp-input");
|
| 1204 |
-
const loginError = $("#login-error");
|
| 1205 |
-
const totpError = $("#totp-error");
|
| 1206 |
-
const splash = $("#splash-screen");
|
| 1207 |
-
|
| 1208 |
-
let authUsername = null;
|
| 1209 |
-
|
| 1210 |
-
// Handle Login submission
|
| 1211 |
-
if (btnLoginSubmit) {
|
| 1212 |
-
btnLoginSubmit.addEventListener("click", async () => {
|
| 1213 |
-
loginError.classList.add("hidden");
|
| 1214 |
-
const userid = loginUsernameIn.value.trim();
|
| 1215 |
-
const password = loginPasswordIn.value;
|
| 1216 |
-
if (!userid || !password) return;
|
| 1217 |
-
|
| 1218 |
-
busy("Verifying credentials...");
|
| 1219 |
-
try {
|
| 1220 |
-
const res = await fetch("/api/auth/login", {
|
| 1221 |
-
method: "POST",
|
| 1222 |
-
headers: { "Content-Type": "application/json" },
|
| 1223 |
-
body: JSON.stringify({ userid, password })
|
| 1224 |
-
});
|
| 1225 |
-
const data = await res.json();
|
| 1226 |
-
unbusy();
|
| 1227 |
-
|
| 1228 |
-
if (res.ok && data.ok) {
|
| 1229 |
-
authUsername = data.userid;
|
| 1230 |
-
if (data.totp_required) {
|
| 1231 |
-
if (data.message === "first_login" || data.message === "totp_setup") {
|
| 1232 |
-
showTotpBox(data.userid, data.qr, data.secret);
|
| 1233 |
-
} else {
|
| 1234 |
-
showTotpBox(data.userid);
|
| 1235 |
-
}
|
| 1236 |
-
}
|
| 1237 |
-
} else {
|
| 1238 |
-
loginError.textContent = data.message || "Invalid credentials.";
|
| 1239 |
-
loginError.classList.remove("hidden");
|
| 1240 |
-
}
|
| 1241 |
-
} catch (err) {
|
| 1242 |
-
unbusy();
|
| 1243 |
-
console.error("Login failed:", err);
|
| 1244 |
-
loginError.textContent = "Login request failed: " + err.message;
|
| 1245 |
-
loginError.classList.remove("hidden");
|
| 1246 |
-
}
|
| 1247 |
-
});
|
| 1248 |
-
}
|
| 1249 |
-
|
| 1250 |
-
// Handle TOTP submission
|
| 1251 |
-
if (btnTotpSubmit) {
|
| 1252 |
-
btnTotpSubmit.addEventListener("click", async () => {
|
| 1253 |
-
totpError.classList.add("hidden");
|
| 1254 |
-
const code = totpIn.value.trim();
|
| 1255 |
-
if (!code) return;
|
| 1256 |
-
|
| 1257 |
-
busy("Verifying TOTP code...");
|
| 1258 |
-
try {
|
| 1259 |
-
const res = await fetch("/api/auth/totp", {
|
| 1260 |
-
method: "POST",
|
| 1261 |
-
headers: { "Content-Type": "application/json" },
|
| 1262 |
-
body: JSON.stringify({ userid: authUsername, code: code })
|
| 1263 |
-
});
|
| 1264 |
-
const data = await res.json();
|
| 1265 |
-
unbusy();
|
| 1266 |
-
|
| 1267 |
-
if (res.ok && data.ok) {
|
| 1268 |
-
localStorage.setItem("session_token", data.session_token);
|
| 1269 |
-
|
| 1270 |
-
const splashStatus = $("#splash-status");
|
| 1271 |
-
if (splashStatus) {
|
| 1272 |
-
splashStatus.classList.remove("hidden");
|
| 1273 |
-
splashStatus.textContent = "Loading market data...";
|
| 1274 |
-
}
|
| 1275 |
-
const loader = $(".splash-loader-container");
|
| 1276 |
-
if (loader) loader.classList.remove("hidden");
|
| 1277 |
-
$("#totp-box").classList.add("hidden");
|
| 1278 |
-
|
| 1279 |
-
// Successfully authenticated! Run check
|
| 1280 |
-
await runBootSequence();
|
| 1281 |
-
} else {
|
| 1282 |
-
totpError.textContent = data.message || "Incorrect code.";
|
| 1283 |
-
totpError.classList.remove("hidden");
|
| 1284 |
-
totpIn.value = "";
|
| 1285 |
-
}
|
| 1286 |
-
} catch (err) {
|
| 1287 |
-
unbusy();
|
| 1288 |
-
console.error("TOTP verification failed:", err);
|
| 1289 |
-
}
|
| 1290 |
-
});
|
| 1291 |
-
}
|
| 1292 |
-
|
| 1293 |
-
// Keyboard support for Enter key
|
| 1294 |
-
if (loginUsernameIn) {
|
| 1295 |
-
[loginUsernameIn, loginPasswordIn].forEach((el) => {
|
| 1296 |
-
el.addEventListener("keydown", (e) => {
|
| 1297 |
-
if (e.key === "Enter") btnLoginSubmit.click();
|
| 1298 |
-
});
|
| 1299 |
-
});
|
| 1300 |
-
}
|
| 1301 |
-
if (totpIn) {
|
| 1302 |
-
totpIn.addEventListener("keydown", (e) => {
|
| 1303 |
-
if (e.key === "Enter") btnTotpSubmit.click();
|
| 1304 |
-
});
|
| 1305 |
-
}
|
| 1306 |
-
|
| 1307 |
-
// Admin Panel Event Handlers
|
| 1308 |
-
$("#admin-close").onclick = () => $("#modal-admin").classList.add("hidden");
|
| 1309 |
-
$("#ai-close").onclick = () => $("#modal-ai").classList.add("hidden");
|
| 1310 |
-
|
| 1311 |
-
const aiRefreshBtn = $("#ai-refresh-btn");
|
| 1312 |
-
if (aiRefreshBtn) {
|
| 1313 |
-
aiRefreshBtn.onclick = () => {
|
| 1314 |
-
if (currentAISymbol) {
|
| 1315 |
-
openAIAnalysisModal(currentAISymbol, true);
|
| 1316 |
-
}
|
| 1317 |
-
};
|
| 1318 |
-
}
|
| 1319 |
-
|
| 1320 |
-
const aiCopyBtn = $("#ai-copy-btn");
|
| 1321 |
-
if (aiCopyBtn) {
|
| 1322 |
-
aiCopyBtn.onclick = () => {
|
| 1323 |
-
if (!currentAIAnalysisText) {
|
| 1324 |
-
toast("No report content to copy", "err");
|
| 1325 |
-
return;
|
| 1326 |
-
}
|
| 1327 |
-
navigator.clipboard.writeText(currentAIAnalysisText).then(() => {
|
| 1328 |
-
toast("AI Research Report copied to clipboard! 📋", "ok");
|
| 1329 |
-
}).catch((err) => {
|
| 1330 |
-
toast("Failed to copy report: " + err, "err");
|
| 1331 |
-
});
|
| 1332 |
-
};
|
| 1333 |
-
}
|
| 1334 |
-
$("#admin-btn-show-add").onclick = () => {
|
| 1335 |
-
showAdminSidebarPanel("panel-add-user");
|
| 1336 |
-
};
|
| 1337 |
-
$("#admin-btn-create-submit").onclick = async () => {
|
| 1338 |
-
const username = $("#add-username").value.trim();
|
| 1339 |
-
const role = $("#add-role").value;
|
| 1340 |
-
if (!username) return;
|
| 1341 |
-
|
| 1342 |
-
const res = await api("/admin/action", {
|
| 1343 |
-
method: "POST",
|
| 1344 |
-
body: JSON.stringify({ action: "add_user", username, role })
|
| 1345 |
-
});
|
| 1346 |
-
if (res.ok) {
|
| 1347 |
-
toast(res.message, "ok");
|
| 1348 |
-
$("#add-username").value = "";
|
| 1349 |
-
hideAllAdminSidebarPanels();
|
| 1350 |
-
await refreshAdminDashboard();
|
| 1351 |
-
}
|
| 1352 |
-
};
|
| 1353 |
-
|
| 1354 |
-
// Fake chat send button support
|
| 1355 |
-
$("#chat-send-btn").onclick = () => {
|
| 1356 |
-
const chatInput = $("#chat-input");
|
| 1357 |
-
const text = chatInput.value.trim();
|
| 1358 |
-
if (!text || !currentAdminActionUser) return;
|
| 1359 |
-
|
| 1360 |
-
const chatDisplay = $("#admin-chat-display");
|
| 1361 |
-
const div = document.createElement("div");
|
| 1362 |
-
div.className = "chat-msg admin";
|
| 1363 |
-
div.innerHTML = `<span><strong>Admin:</strong> ${esc(text)}</span>`;
|
| 1364 |
-
chatDisplay.appendChild(div);
|
| 1365 |
-
chatInput.value = "";
|
| 1366 |
-
chatDisplay.scrollTop = chatDisplay.scrollHeight;
|
| 1367 |
-
|
| 1368 |
-
// Simulated reply
|
| 1369 |
-
setTimeout(() => {
|
| 1370 |
-
const reply = document.createElement("div");
|
| 1371 |
-
reply.className = "chat-msg user";
|
| 1372 |
-
reply.innerHTML = `<span><strong>${esc(currentAdminActionUser)}</strong>: Thanks admin! Got it.</span>`;
|
| 1373 |
-
chatDisplay.appendChild(reply);
|
| 1374 |
-
chatDisplay.scrollTop = chatDisplay.scrollHeight;
|
| 1375 |
-
}, 1000);
|
| 1376 |
-
};
|
| 1377 |
-
|
| 1378 |
-
$("#chat-input").addEventListener("keydown", (e) => {
|
| 1379 |
-
if (e.key === "Enter") $("#chat-send-btn").click();
|
| 1380 |
-
});
|
| 1381 |
-
|
| 1382 |
-
// Settings -> Change Password Event Handlers
|
| 1383 |
-
const btnSettingsChangePassword = $("#btn-settings-change-password");
|
| 1384 |
-
if (btnSettingsChangePassword) {
|
| 1385 |
-
btnSettingsChangePassword.addEventListener("click", () => {
|
| 1386 |
-
closeMenus();
|
| 1387 |
-
$("#cp-current").value = "";
|
| 1388 |
-
$("#cp-new").value = "";
|
| 1389 |
-
$("#cp-confirm").value = "";
|
| 1390 |
-
$("#cp-error").classList.add("hidden");
|
| 1391 |
-
$("#modal-change-password").classList.remove("hidden");
|
| 1392 |
-
});
|
| 1393 |
-
}
|
| 1394 |
-
|
| 1395 |
-
$("#cp-cancel").addEventListener("click", () => {
|
| 1396 |
-
$("#modal-change-password").classList.add("hidden");
|
| 1397 |
-
});
|
| 1398 |
-
|
| 1399 |
-
$("#cp-submit").addEventListener("click", async () => {
|
| 1400 |
-
const current_password = $("#cp-current").value;
|
| 1401 |
-
const new_password = $("#cp-new").value;
|
| 1402 |
-
const confirm_password = $("#cp-confirm").value;
|
| 1403 |
-
const cpError = $("#cp-error");
|
| 1404 |
-
|
| 1405 |
-
if (new_password !== confirm_password) {
|
| 1406 |
-
cpError.textContent = "New password and confirm password do not match.";
|
| 1407 |
-
cpError.classList.remove("hidden");
|
| 1408 |
-
return;
|
| 1409 |
-
}
|
| 1410 |
-
|
| 1411 |
-
try {
|
| 1412 |
-
const res = await api("/auth/change-password", {
|
| 1413 |
-
method: "POST",
|
| 1414 |
-
body: JSON.stringify({ current_password, new_password, confirm_password })
|
| 1415 |
-
});
|
| 1416 |
-
if (res.ok) {
|
| 1417 |
-
toast(res.message || "Password updated successfully!", "ok");
|
| 1418 |
-
$("#modal-change-password").classList.add("hidden");
|
| 1419 |
-
} else {
|
| 1420 |
-
cpError.textContent = res.message || "Failed to update password.";
|
| 1421 |
-
cpError.classList.remove("hidden");
|
| 1422 |
-
}
|
| 1423 |
-
} catch (err) {
|
| 1424 |
-
cpError.textContent = "Error changing password: " + err;
|
| 1425 |
-
cpError.classList.remove("hidden");
|
| 1426 |
-
}
|
| 1427 |
-
});
|
| 1428 |
-
|
| 1429 |
-
// Mandatory Policy Password Change Submission
|
| 1430 |
-
$("#force-cp-submit").addEventListener("click", async () => {
|
| 1431 |
-
const new_password = $("#force-cp-new").value;
|
| 1432 |
-
const confirm_password = $("#force-cp-confirm").value;
|
| 1433 |
-
const forceCpError = $("#force-cp-error");
|
| 1434 |
-
|
| 1435 |
-
if (new_password !== confirm_password) {
|
| 1436 |
-
forceCpError.textContent = "New password and confirm password do not match.";
|
| 1437 |
-
forceCpError.classList.remove("hidden");
|
| 1438 |
-
return;
|
| 1439 |
-
}
|
| 1440 |
-
|
| 1441 |
-
try {
|
| 1442 |
-
const res = await api("/auth/change-password", {
|
| 1443 |
-
method: "POST",
|
| 1444 |
-
body: JSON.stringify({ new_password, confirm_password })
|
| 1445 |
-
});
|
| 1446 |
-
if (res.ok) {
|
| 1447 |
-
toast("Compliant password saved successfully!", "ok");
|
| 1448 |
-
$("#modal-force-password-change").classList.add("hidden");
|
| 1449 |
-
await runBootSequence();
|
| 1450 |
-
} else {
|
| 1451 |
-
forceCpError.textContent = res.message || "Failed to update password.";
|
| 1452 |
-
forceCpError.classList.remove("hidden");
|
| 1453 |
-
}
|
| 1454 |
-
} catch (err) {
|
| 1455 |
-
forceCpError.textContent = "Error saving password: " + err;
|
| 1456 |
-
forceCpError.classList.remove("hidden");
|
| 1457 |
-
}
|
| 1458 |
-
});
|
| 1459 |
-
|
| 1460 |
-
async function runBootSequence() {
|
| 1461 |
-
const res = await fetch("/api/auth/status", {
|
| 1462 |
-
headers: { "X-Session-Token": localStorage.getItem("session_token") || "" }
|
| 1463 |
-
});
|
| 1464 |
-
const status = await res.json();
|
| 1465 |
-
|
| 1466 |
-
const splashStatus = $("#splash-status");
|
| 1467 |
-
if (status.nvidia_status) {
|
| 1468 |
-
if (status.nvidia_status.success) {
|
| 1469 |
-
if (splashStatus) splashStatus.textContent = "System Initialized — NVIDIA API connection tested and successful ✔";
|
| 1470 |
-
toast(`NVIDIA API connection tested & successful (${status.nvidia_status.source || 'Keyring'})`, "ok");
|
| 1471 |
-
} else {
|
| 1472 |
-
if (splashStatus) splashStatus.textContent = "System Initialized — " + (status.nvidia_status.message || "NVIDIA API Key warning");
|
| 1473 |
-
}
|
| 1474 |
-
}
|
| 1475 |
-
|
| 1476 |
-
if (status.authenticated) {
|
| 1477 |
-
$("#display-username").textContent = status.userid;
|
| 1478 |
-
if (status.is_admin) {
|
| 1479 |
-
$("#menu-utilities-container").classList.remove("hidden");
|
| 1480 |
-
loadUtilitiesMenu();
|
| 1481 |
-
} else {
|
| 1482 |
-
$("#menu-utilities-container").classList.add("hidden");
|
| 1483 |
-
}
|
| 1484 |
-
|
| 1485 |
-
if (status.force_password_change) {
|
| 1486 |
-
$("#modal-force-password-change").classList.remove("hidden");
|
| 1487 |
-
return;
|
| 1488 |
-
}
|
| 1489 |
-
|
| 1490 |
-
await loadBoard();
|
| 1491 |
-
startHeartbeat();
|
| 1492 |
-
|
| 1493 |
-
if (splash) {
|
| 1494 |
-
splash.classList.add("fade-out");
|
| 1495 |
-
setTimeout(() => {
|
| 1496 |
-
splash.classList.add("hidden");
|
| 1497 |
-
splash.classList.remove("fade-out");
|
| 1498 |
-
}, 600);
|
| 1499 |
-
}
|
| 1500 |
-
} else {
|
| 1501 |
-
showLoginBox();
|
| 1502 |
-
if (status.reason === "inactivity") {
|
| 1503 |
-
toast("Session terminated due to inactivity.", "err");
|
| 1504 |
-
const loginError = $("#login-error");
|
| 1505 |
-
if (loginError) {
|
| 1506 |
-
loginError.textContent = "Session terminated due to inactivity.";
|
| 1507 |
-
loginError.classList.remove("hidden");
|
| 1508 |
-
}
|
| 1509 |
-
}
|
| 1510 |
-
}
|
| 1511 |
-
}
|
| 1512 |
-
|
| 1513 |
-
// Handle Logout button click
|
| 1514 |
-
const btnLogout = $("#btn-logout");
|
| 1515 |
-
if (btnLogout) {
|
| 1516 |
-
btnLogout.addEventListener("click", async () => {
|
| 1517 |
-
busy("Logging out...");
|
| 1518 |
-
try {
|
| 1519 |
-
await fetch("/api/auth/logout", {
|
| 1520 |
-
method: "POST",
|
| 1521 |
-
headers: {
|
| 1522 |
-
"Content-Type": "application/json",
|
| 1523 |
-
"X-Session-Token": localStorage.getItem("session_token") || ""
|
| 1524 |
-
}
|
| 1525 |
-
});
|
| 1526 |
-
} catch (err) {
|
| 1527 |
-
console.error("Logout request failed:", err);
|
| 1528 |
-
}
|
| 1529 |
-
localStorage.removeItem("session_token");
|
| 1530 |
-
|
| 1531 |
-
// Stop live refresh heartbeat if active
|
| 1532 |
-
if (state.livePoll) { clearInterval(state.livePoll); state.livePoll = null; }
|
| 1533 |
-
if (state.heartbeat) { clearInterval(state.heartbeat); state.heartbeat = null; }
|
| 1534 |
-
|
| 1535 |
-
authUsername = null;
|
| 1536 |
-
$("#login-username").value = "";
|
| 1537 |
-
$("#login-password").value = "";
|
| 1538 |
-
$("#totp-input").value = "";
|
| 1539 |
-
unbusy();
|
| 1540 |
-
showLoginBox();
|
| 1541 |
-
});
|
| 1542 |
-
}
|
| 1543 |
-
|
| 1544 |
-
// Start checking auth status on page load
|
| 1545 |
-
runBootSequence();
|
| 1546 |
-
});
|
| 1547 |
-
|
| 1548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/static/style.css
DELETED
|
@@ -1,924 +0,0 @@
|
|
| 1 |
-
/* NSE Stock Dashboard — market-terminal styling.
|
| 2 |
-
Numeric-heavy layout: tabular figures, market-convention colours. */
|
| 3 |
-
|
| 4 |
-
:root {
|
| 5 |
-
--bg: #0e1116;
|
| 6 |
-
--panel: #161b22;
|
| 7 |
-
--panel-2: #1c232d;
|
| 8 |
-
--border: #2a333f;
|
| 9 |
-
--text: #dbe2ea;
|
| 10 |
-
--muted: #94a3b8;
|
| 11 |
-
--gain: #4ade80;
|
| 12 |
-
--loss: #ff7675;
|
| 13 |
-
--flat: #a1a1aa;
|
| 14 |
-
--accent: #60a5fa;
|
| 15 |
-
--amber: #f59e0b;
|
| 16 |
-
--live: #1f7a3d;
|
| 17 |
-
--historic: #8a2f2a;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
* { box-sizing: border-box; }
|
| 21 |
-
html, body { margin: 0; }
|
| 22 |
-
body {
|
| 23 |
-
background: var(--bg);
|
| 24 |
-
color: var(--text);
|
| 25 |
-
font: 13px/1.45 "Segoe UI", system-ui, -apple-system, sans-serif;
|
| 26 |
-
}
|
| 27 |
-
b, .num { font-variant-numeric: tabular-nums; }
|
| 28 |
-
button {
|
| 29 |
-
background: var(--panel-2);
|
| 30 |
-
color: var(--text);
|
| 31 |
-
border: 1px solid var(--border);
|
| 32 |
-
border-radius: 4px;
|
| 33 |
-
padding: 5px 10px;
|
| 34 |
-
cursor: pointer;
|
| 35 |
-
}
|
| 36 |
-
button:hover { border-color: var(--accent); }
|
| 37 |
-
button:focus-visible, input:focus-visible, select:focus-visible {
|
| 38 |
-
outline: 2px solid var(--accent);
|
| 39 |
-
outline-offset: 1px;
|
| 40 |
-
}
|
| 41 |
-
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
| 42 |
-
button.danger { border-color: var(--loss); color: #e07b70; }
|
| 43 |
-
input, select {
|
| 44 |
-
background: var(--panel);
|
| 45 |
-
color: var(--text);
|
| 46 |
-
border: 1px solid var(--border);
|
| 47 |
-
border-radius: 4px;
|
| 48 |
-
padding: 5px 8px;
|
| 49 |
-
}
|
| 50 |
-
.muted { color: var(--muted); }
|
| 51 |
-
.hidden { display: none !important; }
|
| 52 |
-
|
| 53 |
-
/* Banner */
|
| 54 |
-
.banner {
|
| 55 |
-
display: flex;
|
| 56 |
-
justify-content: space-between;
|
| 57 |
-
padding: 6px 14px;
|
| 58 |
-
font-weight: 600;
|
| 59 |
-
color: #fff;
|
| 60 |
-
letter-spacing: .02em;
|
| 61 |
-
}
|
| 62 |
-
.banner.live { background: var(--live); }
|
| 63 |
-
.banner.historic { background: var(--historic); }
|
| 64 |
-
|
| 65 |
-
.env-pill {
|
| 66 |
-
display: inline-block;
|
| 67 |
-
padding: 2px 6px;
|
| 68 |
-
font-size: 10px;
|
| 69 |
-
font-weight: 700;
|
| 70 |
-
border-radius: 12px;
|
| 71 |
-
text-transform: uppercase;
|
| 72 |
-
letter-spacing: 0.05em;
|
| 73 |
-
line-height: 1;
|
| 74 |
-
}
|
| 75 |
-
.env-pill.local {
|
| 76 |
-
background: rgba(59, 130, 196, 0.25);
|
| 77 |
-
color: #5dade2;
|
| 78 |
-
border: 1px solid rgba(59, 130, 196, 0.4);
|
| 79 |
-
}
|
| 80 |
-
.env-pill.hf {
|
| 81 |
-
background: rgba(241, 196, 15, 0.25);
|
| 82 |
-
color: #f1c40f;
|
| 83 |
-
border: 1px solid rgba(241, 196, 15, 0.4);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
/* Menu bar */
|
| 87 |
-
.menubar {
|
| 88 |
-
display: flex;
|
| 89 |
-
gap: 2px;
|
| 90 |
-
background: var(--panel);
|
| 91 |
-
border-bottom: 1px solid var(--border);
|
| 92 |
-
padding: 0 8px;
|
| 93 |
-
}
|
| 94 |
-
.menu { position: relative; }
|
| 95 |
-
.menu-btn {
|
| 96 |
-
border: none;
|
| 97 |
-
background: none;
|
| 98 |
-
border-radius: 0;
|
| 99 |
-
padding: 8px 12px;
|
| 100 |
-
}
|
| 101 |
-
.menu-btn:hover { background: var(--panel-2); }
|
| 102 |
-
.menu-drop {
|
| 103 |
-
position: absolute;
|
| 104 |
-
top: 100%;
|
| 105 |
-
left: 0;
|
| 106 |
-
min-width: 230px;
|
| 107 |
-
max-height: 60vh;
|
| 108 |
-
overflow: auto;
|
| 109 |
-
background: var(--panel-2);
|
| 110 |
-
border: 1px solid var(--border);
|
| 111 |
-
border-radius: 0 0 6px 6px;
|
| 112 |
-
display: none;
|
| 113 |
-
z-index: 40;
|
| 114 |
-
}
|
| 115 |
-
.menu.open .menu-drop { display: block; }
|
| 116 |
-
.menu-drop button {
|
| 117 |
-
display: block;
|
| 118 |
-
width: 100%;
|
| 119 |
-
text-align: left;
|
| 120 |
-
border: none;
|
| 121 |
-
border-radius: 0;
|
| 122 |
-
padding: 7px 12px;
|
| 123 |
-
}
|
| 124 |
-
.menu-drop button:hover { background: var(--accent); color: #fff; }
|
| 125 |
-
.menu-drop .sep { border-top: 1px solid var(--border); margin: 4px 0; }
|
| 126 |
-
.menu-drop .head {
|
| 127 |
-
padding: 6px 12px 2px;
|
| 128 |
-
font-size: 11px;
|
| 129 |
-
text-transform: uppercase;
|
| 130 |
-
color: var(--muted);
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
/* Toolbar */
|
| 134 |
-
.toolbar {
|
| 135 |
-
display: flex;
|
| 136 |
-
align-items: center;
|
| 137 |
-
flex-wrap: wrap;
|
| 138 |
-
gap: 10px;
|
| 139 |
-
padding: 8px 12px;
|
| 140 |
-
background: var(--panel);
|
| 141 |
-
border-bottom: 1px solid var(--border);
|
| 142 |
-
}
|
| 143 |
-
.toggle { display: flex; align-items: center; gap: 6px; }
|
| 144 |
-
.pf-summary {
|
| 145 |
-
display: flex;
|
| 146 |
-
gap: 16px;
|
| 147 |
-
padding: 4px 12px;
|
| 148 |
-
background: var(--panel-2);
|
| 149 |
-
border: 1px solid var(--border);
|
| 150 |
-
border-radius: 4px;
|
| 151 |
-
flex-wrap: wrap;
|
| 152 |
-
}
|
| 153 |
-
.pf-summary span { color: var(--muted); }
|
| 154 |
-
.pf-summary b { color: var(--text); margin-left: 4px; }
|
| 155 |
-
.pf-summary b.gain { color: var(--gain); }
|
| 156 |
-
.pf-summary b.loss { color: var(--loss); }
|
| 157 |
-
|
| 158 |
-
/* Live Index Tickers & RTC Clock */
|
| 159 |
-
.index-tickers {
|
| 160 |
-
display: flex;
|
| 161 |
-
align-items: center;
|
| 162 |
-
gap: 8px;
|
| 163 |
-
}
|
| 164 |
-
.index-badge {
|
| 165 |
-
display: flex;
|
| 166 |
-
align-items: center;
|
| 167 |
-
gap: 6px;
|
| 168 |
-
background: var(--panel-2);
|
| 169 |
-
border: 1px solid var(--border);
|
| 170 |
-
border-radius: 4px;
|
| 171 |
-
padding: 3px 8px;
|
| 172 |
-
cursor: pointer;
|
| 173 |
-
transition: all 0.2s ease;
|
| 174 |
-
}
|
| 175 |
-
.index-badge:hover {
|
| 176 |
-
border-color: var(--accent);
|
| 177 |
-
background: rgba(59, 130, 196, 0.15);
|
| 178 |
-
}
|
| 179 |
-
.idx-name {
|
| 180 |
-
font-size: 11px;
|
| 181 |
-
font-weight: 700;
|
| 182 |
-
color: #94a3b8;
|
| 183 |
-
letter-spacing: 0.04em;
|
| 184 |
-
}
|
| 185 |
-
.idx-pill {
|
| 186 |
-
font-size: 11px;
|
| 187 |
-
font-weight: 700;
|
| 188 |
-
font-variant-numeric: tabular-nums;
|
| 189 |
-
padding: 2px 6px;
|
| 190 |
-
border-radius: 3px;
|
| 191 |
-
background: rgba(255, 255, 255, 0.08);
|
| 192 |
-
}
|
| 193 |
-
.idx-pill.positive {
|
| 194 |
-
color: var(--gain);
|
| 195 |
-
background: rgba(39, 174, 96, 0.18);
|
| 196 |
-
border: 1px solid rgba(39, 174, 96, 0.3);
|
| 197 |
-
}
|
| 198 |
-
.idx-pill.negative {
|
| 199 |
-
color: var(--loss);
|
| 200 |
-
background: rgba(224, 123, 112, 0.18);
|
| 201 |
-
border: 1px solid rgba(224, 123, 112, 0.3);
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
.date-rtc-wrap {
|
| 205 |
-
margin-left: auto;
|
| 206 |
-
display: flex;
|
| 207 |
-
align-items: center;
|
| 208 |
-
gap: 14px;
|
| 209 |
-
}
|
| 210 |
-
.rtc-clock {
|
| 211 |
-
display: flex;
|
| 212 |
-
align-items: center;
|
| 213 |
-
gap: 6px;
|
| 214 |
-
background: rgba(15, 23, 42, 0.7);
|
| 215 |
-
border: 1px solid var(--border);
|
| 216 |
-
border-radius: 4px;
|
| 217 |
-
padding: 4px 10px;
|
| 218 |
-
font-family: "JetBrains Mono", "Consolas", monospace;
|
| 219 |
-
font-size: 11px;
|
| 220 |
-
font-weight: 600;
|
| 221 |
-
color: #38bdf8;
|
| 222 |
-
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 223 |
-
}
|
| 224 |
-
.rtc-icon {
|
| 225 |
-
font-size: 12px;
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
.datewrap { margin-left: 0; display: flex; align-items: center; gap: 6px; }
|
| 229 |
-
|
| 230 |
-
/* Add row */
|
| 231 |
-
.addrow {
|
| 232 |
-
display: flex;
|
| 233 |
-
gap: 8px;
|
| 234 |
-
align-items: center;
|
| 235 |
-
padding: 8px 12px;
|
| 236 |
-
}
|
| 237 |
-
#stock-search { flex: 0 1 420px; }
|
| 238 |
-
|
| 239 |
-
/* Grid */
|
| 240 |
-
.grid {
|
| 241 |
-
display: grid;
|
| 242 |
-
grid-template-columns: repeat(auto-fill, minmax(320px, 420px));
|
| 243 |
-
gap: 12px;
|
| 244 |
-
padding: 0 12px 60px;
|
| 245 |
-
justify-content: center;
|
| 246 |
-
max-width: 1680px;
|
| 247 |
-
margin: 0 auto;
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
/* Card */
|
| 251 |
-
.card {
|
| 252 |
-
background: var(--panel);
|
| 253 |
-
border: 1px solid var(--border);
|
| 254 |
-
border-radius: 8px;
|
| 255 |
-
padding: 10px 12px;
|
| 256 |
-
position: relative;
|
| 257 |
-
}
|
| 258 |
-
.card h4 { margin: 0; font-size: 14px; }
|
| 259 |
-
.card .sec { font-size: 11px; color: var(--muted); }
|
| 260 |
-
.card .rm {
|
| 261 |
-
position: absolute;
|
| 262 |
-
top: 8px;
|
| 263 |
-
right: 8px;
|
| 264 |
-
padding: 0 7px;
|
| 265 |
-
line-height: 20px;
|
| 266 |
-
}
|
| 267 |
-
.card .row {
|
| 268 |
-
display: flex;
|
| 269 |
-
justify-content: space-between;
|
| 270 |
-
gap: 8px;
|
| 271 |
-
margin: 2px 0;
|
| 272 |
-
}
|
| 273 |
-
.card .lbl { color: var(--muted); }
|
| 274 |
-
.gain { color: var(--gain); }
|
| 275 |
-
.loss { color: var(--loss); }
|
| 276 |
-
.flat { color: var(--flat); }
|
| 277 |
-
.amber { color: var(--amber); }
|
| 278 |
-
.score-80 { color: #2ecc71; }
|
| 279 |
-
.score-60 { color: #a3d977; }
|
| 280 |
-
.score-40 { color: var(--amber); }
|
| 281 |
-
.score-20 { color: #e67e22; }
|
| 282 |
-
.score-0 { color: var(--loss); }
|
| 283 |
-
.changes, .volchips {
|
| 284 |
-
display: flex;
|
| 285 |
-
gap: 6px;
|
| 286 |
-
flex-wrap: wrap;
|
| 287 |
-
margin: 4px 0;
|
| 288 |
-
}
|
| 289 |
-
.chip {
|
| 290 |
-
background: var(--panel-2);
|
| 291 |
-
border: 1px solid var(--border);
|
| 292 |
-
border-radius: 4px;
|
| 293 |
-
padding: 2px 6px;
|
| 294 |
-
font-size: 11px;
|
| 295 |
-
}
|
| 296 |
-
.card canvas { width: 100%; height: auto; margin-top: 6px; }
|
| 297 |
-
.live-row {
|
| 298 |
-
margin-top: 6px;
|
| 299 |
-
padding: 5px 8px;
|
| 300 |
-
background: var(--panel-2);
|
| 301 |
-
border-left: 3px solid var(--live);
|
| 302 |
-
border-radius: 4px;
|
| 303 |
-
font-size: 12px;
|
| 304 |
-
}
|
| 305 |
-
.inputs {
|
| 306 |
-
display: grid;
|
| 307 |
-
grid-template-columns: 1fr 1fr;
|
| 308 |
-
gap: 6px;
|
| 309 |
-
margin-top: 8px;
|
| 310 |
-
}
|
| 311 |
-
.inputs label { font-size: 11px; color: var(--muted); }
|
| 312 |
-
.inputs input { width: 100%; }
|
| 313 |
-
.pl-row { margin-top: 6px; font-size: 12px; }
|
| 314 |
-
.card-foot {
|
| 315 |
-
display: flex;
|
| 316 |
-
justify-content: space-between;
|
| 317 |
-
align-items: center;
|
| 318 |
-
margin-top: 8px;
|
| 319 |
-
}
|
| 320 |
-
.reco { font-size: 12px; }
|
| 321 |
-
|
| 322 |
-
/* Bottom controls */
|
| 323 |
-
.controls {
|
| 324 |
-
position: fixed;
|
| 325 |
-
bottom: 0;
|
| 326 |
-
left: 0;
|
| 327 |
-
right: 0;
|
| 328 |
-
display: flex;
|
| 329 |
-
gap: 8px;
|
| 330 |
-
padding: 8px 12px;
|
| 331 |
-
background: var(--panel);
|
| 332 |
-
border-top: 1px solid var(--border);
|
| 333 |
-
z-index: 30;
|
| 334 |
-
}
|
| 335 |
-
|
| 336 |
-
/* Overlays / modals */
|
| 337 |
-
.overlay {
|
| 338 |
-
position: fixed;
|
| 339 |
-
inset: 0;
|
| 340 |
-
background: rgba(0,0,0,.55);
|
| 341 |
-
display: flex;
|
| 342 |
-
align-items: center;
|
| 343 |
-
justify-content: center;
|
| 344 |
-
z-index: 60;
|
| 345 |
-
}
|
| 346 |
-
.modal {
|
| 347 |
-
background: var(--panel);
|
| 348 |
-
border: 1px solid var(--border);
|
| 349 |
-
border-radius: 8px;
|
| 350 |
-
padding: 18px;
|
| 351 |
-
min-width: 320px;
|
| 352 |
-
display: flex;
|
| 353 |
-
flex-direction: column;
|
| 354 |
-
gap: 10px;
|
| 355 |
-
}
|
| 356 |
-
.modal h3 { margin: 0 0 4px; }
|
| 357 |
-
.modal label { display: flex; flex-direction: column; gap: 4px; }
|
| 358 |
-
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
| 359 |
-
.busy-box {
|
| 360 |
-
background: var(--panel);
|
| 361 |
-
border: 1px solid var(--border);
|
| 362 |
-
border-radius: 8px;
|
| 363 |
-
padding: 22px 30px;
|
| 364 |
-
display: flex;
|
| 365 |
-
align-items: center;
|
| 366 |
-
gap: 14px;
|
| 367 |
-
}
|
| 368 |
-
.spinner {
|
| 369 |
-
width: 22px;
|
| 370 |
-
height: 22px;
|
| 371 |
-
border: 3px solid var(--border);
|
| 372 |
-
border-top-color: var(--accent);
|
| 373 |
-
border-radius: 50%;
|
| 374 |
-
animation: spin 0.9s linear infinite;
|
| 375 |
-
}
|
| 376 |
-
@keyframes spin { to { transform: rotate(360deg); } }
|
| 377 |
-
@media (prefers-reduced-motion: reduce) {
|
| 378 |
-
.spinner { animation-duration: 2.5s; }
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
/* Terminal */
|
| 382 |
-
.modal.terminal { width: min(860px, 92vw); }
|
| 383 |
-
#terminal-out {
|
| 384 |
-
background: #0a0d11;
|
| 385 |
-
border: 1px solid var(--border);
|
| 386 |
-
border-radius: 4px;
|
| 387 |
-
height: 50vh;
|
| 388 |
-
overflow: auto;
|
| 389 |
-
padding: 10px;
|
| 390 |
-
margin: 0;
|
| 391 |
-
font: 12px/1.5 "Consolas", "SFMono-Regular", monospace;
|
| 392 |
-
white-space: pre-wrap;
|
| 393 |
-
}
|
| 394 |
-
#terminal-out .info { color: var(--muted); }
|
| 395 |
-
#terminal-out .error { color: #e07b70; }
|
| 396 |
-
#terminal-out .success { color: var(--gain); }
|
| 397 |
-
#terminal-out .input { color: var(--accent); }
|
| 398 |
-
.terminal-input { display: flex; gap: 8px; }
|
| 399 |
-
.terminal-input input { flex: 1; }
|
| 400 |
-
|
| 401 |
-
/* Toasts */
|
| 402 |
-
#toasts {
|
| 403 |
-
position: fixed;
|
| 404 |
-
right: 14px;
|
| 405 |
-
bottom: 56px;
|
| 406 |
-
display: flex;
|
| 407 |
-
flex-direction: column;
|
| 408 |
-
gap: 8px;
|
| 409 |
-
z-index: 80;
|
| 410 |
-
}
|
| 411 |
-
.toast {
|
| 412 |
-
background: var(--panel-2);
|
| 413 |
-
border: 1px solid var(--border);
|
| 414 |
-
border-left: 3px solid var(--accent);
|
| 415 |
-
border-radius: 6px;
|
| 416 |
-
padding: 10px 14px;
|
| 417 |
-
max-width: 420px;
|
| 418 |
-
box-shadow: 0 4px 14px rgba(0,0,0,.4);
|
| 419 |
-
}
|
| 420 |
-
.toast.err { border-left-color: var(--loss); }
|
| 421 |
-
.toast.ok { border-left-color: var(--gain); }
|
| 422 |
-
|
| 423 |
-
@media (max-width: 720px) {
|
| 424 |
-
.pf-summary { order: 5; width: 100%; }
|
| 425 |
-
.datewrap { margin-left: 0; }
|
| 426 |
-
}
|
| 427 |
-
|
| 428 |
-
/* Splash Screen Styles */
|
| 429 |
-
.splash-screen {
|
| 430 |
-
position: fixed;
|
| 431 |
-
top: 0;
|
| 432 |
-
left: 0;
|
| 433 |
-
width: 100vw;
|
| 434 |
-
height: 100vh;
|
| 435 |
-
background: radial-gradient(circle at center, #1b2230 0%, #0e1116 100%);
|
| 436 |
-
display: flex;
|
| 437 |
-
justify-content: center;
|
| 438 |
-
align-items: center;
|
| 439 |
-
z-index: 9999;
|
| 440 |
-
opacity: 1;
|
| 441 |
-
visibility: visible;
|
| 442 |
-
transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 443 |
-
}
|
| 444 |
-
|
| 445 |
-
.splash-screen.fade-out {
|
| 446 |
-
opacity: 0;
|
| 447 |
-
visibility: hidden;
|
| 448 |
-
pointer-events: none;
|
| 449 |
-
}
|
| 450 |
-
|
| 451 |
-
.splash-content {
|
| 452 |
-
text-align: center;
|
| 453 |
-
animation: splashFadeIn 1s cubic-bezier(0.16, 1, 0.3, 1);
|
| 454 |
-
padding: 20px;
|
| 455 |
-
}
|
| 456 |
-
|
| 457 |
-
.splash-logo {
|
| 458 |
-
font-size: 42px;
|
| 459 |
-
font-weight: 800;
|
| 460 |
-
letter-spacing: -0.5px;
|
| 461 |
-
margin-bottom: 8px;
|
| 462 |
-
color: #ffffff;
|
| 463 |
-
}
|
| 464 |
-
|
| 465 |
-
.splash-brand-nse {
|
| 466 |
-
background: linear-gradient(135deg, #3b82c4 0%, #27ae60 100%);
|
| 467 |
-
-webkit-background-clip: text;
|
| 468 |
-
-webkit-text-fill-color: transparent;
|
| 469 |
-
padding-right: 2px;
|
| 470 |
-
}
|
| 471 |
-
|
| 472 |
-
.splash-brand-dash {
|
| 473 |
-
color: #ffffff;
|
| 474 |
-
text-shadow: 0 0 20px rgba(59, 130, 196, 0.3);
|
| 475 |
-
}
|
| 476 |
-
|
| 477 |
-
.splash-subtitle {
|
| 478 |
-
font-size: 13px;
|
| 479 |
-
color: var(--muted);
|
| 480 |
-
letter-spacing: 2px;
|
| 481 |
-
text-transform: uppercase;
|
| 482 |
-
margin-bottom: 30px;
|
| 483 |
-
}
|
| 484 |
-
|
| 485 |
-
.splash-loader-container {
|
| 486 |
-
width: 220px;
|
| 487 |
-
height: 4px;
|
| 488 |
-
background: var(--border);
|
| 489 |
-
border-radius: 2px;
|
| 490 |
-
margin: 0 auto 12px;
|
| 491 |
-
overflow: hidden;
|
| 492 |
-
position: relative;
|
| 493 |
-
}
|
| 494 |
-
|
| 495 |
-
.splash-loader-bar {
|
| 496 |
-
width: 100%;
|
| 497 |
-
height: 100%;
|
| 498 |
-
background: linear-gradient(90deg, #3b82c4, #27ae60, #3b82c4);
|
| 499 |
-
background-size: 200% 100%;
|
| 500 |
-
position: absolute;
|
| 501 |
-
left: -100%;
|
| 502 |
-
animation: loadingProgress 2s infinite linear, gradientMove 3s infinite linear;
|
| 503 |
-
}
|
| 504 |
-
|
| 505 |
-
.splash-status {
|
| 506 |
-
font-size: 11px;
|
| 507 |
-
color: var(--muted);
|
| 508 |
-
font-family: monospace;
|
| 509 |
-
}
|
| 510 |
-
|
| 511 |
-
@keyframes splashFadeIn {
|
| 512 |
-
from {
|
| 513 |
-
opacity: 0;
|
| 514 |
-
transform: translateY(20px);
|
| 515 |
-
}
|
| 516 |
-
to {
|
| 517 |
-
opacity: 1;
|
| 518 |
-
transform: translateY(0);
|
| 519 |
-
}
|
| 520 |
-
}
|
| 521 |
-
|
| 522 |
-
@keyframes loadingProgress {
|
| 523 |
-
0% { left: -100%; }
|
| 524 |
-
50% { left: 0%; }
|
| 525 |
-
100% { left: 100%; }
|
| 526 |
-
}
|
| 527 |
-
|
| 528 |
-
@keyframes gradientMove {
|
| 529 |
-
0% { background-position: 0% 50%; }
|
| 530 |
-
100% { background-position: 200% 50%; }
|
| 531 |
-
}
|
| 532 |
-
|
| 533 |
-
.captcha-box {
|
| 534 |
-
margin-top: 24px;
|
| 535 |
-
padding: 20px;
|
| 536 |
-
border-radius: 12px;
|
| 537 |
-
background: rgba(255, 255, 255, 0.03);
|
| 538 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 539 |
-
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
| 540 |
-
backdrop-filter: blur(5px);
|
| 541 |
-
-webkit-backdrop-filter: blur(5px);
|
| 542 |
-
animation: splashFadeIn 0.5s ease-out;
|
| 543 |
-
}
|
| 544 |
-
.captcha-box p {
|
| 545 |
-
margin: 0 0 14px 0;
|
| 546 |
-
font-size: 14px;
|
| 547 |
-
color: #ccc;
|
| 548 |
-
font-family: inherit;
|
| 549 |
-
}
|
| 550 |
-
.captcha-box .captcha-row {
|
| 551 |
-
display: flex;
|
| 552 |
-
gap: 12px;
|
| 553 |
-
align-items: center;
|
| 554 |
-
justify-content: center;
|
| 555 |
-
}
|
| 556 |
-
.captcha-box input {
|
| 557 |
-
width: 90px;
|
| 558 |
-
padding: 8px;
|
| 559 |
-
border-radius: 6px;
|
| 560 |
-
border: 1px solid rgba(255, 255, 255, 0.15);
|
| 561 |
-
background: rgba(255, 255, 255, 0.07);
|
| 562 |
-
color: #ffffff;
|
| 563 |
-
font-size: 16px;
|
| 564 |
-
text-align: center;
|
| 565 |
-
outline: none;
|
| 566 |
-
transition: border-color 0.3s;
|
| 567 |
-
}
|
| 568 |
-
.captcha-box input:focus {
|
| 569 |
-
border-color: #3b82c4;
|
| 570 |
-
}
|
| 571 |
-
.captcha-box button {
|
| 572 |
-
padding: 8px 18px;
|
| 573 |
-
border-radius: 6px;
|
| 574 |
-
background: var(--live);
|
| 575 |
-
color: #ffffff;
|
| 576 |
-
border: none;
|
| 577 |
-
cursor: pointer;
|
| 578 |
-
font-weight: 700;
|
| 579 |
-
transition: filter 0.3s;
|
| 580 |
-
}
|
| 581 |
-
.captcha-box button:hover {
|
| 582 |
-
filter: brightness(1.15);
|
| 583 |
-
}
|
| 584 |
-
.captcha-box .captcha-err {
|
| 585 |
-
color: var(--loss);
|
| 586 |
-
font-size: 12px;
|
| 587 |
-
margin: 10px 0 0 0;
|
| 588 |
-
font-weight: bold;
|
| 589 |
-
}
|
| 590 |
-
|
| 591 |
-
/* Shutdown Screen Styles */
|
| 592 |
-
.shutdown-screen {
|
| 593 |
-
position: fixed;
|
| 594 |
-
top: 0;
|
| 595 |
-
left: 0;
|
| 596 |
-
width: 100vw;
|
| 597 |
-
height: 100vh;
|
| 598 |
-
background: radial-gradient(circle at center, #2c1a1d 0%, #0e0b0c 100%);
|
| 599 |
-
display: flex;
|
| 600 |
-
justify-content: center;
|
| 601 |
-
align-items: center;
|
| 602 |
-
z-index: 10000;
|
| 603 |
-
opacity: 0;
|
| 604 |
-
visibility: hidden;
|
| 605 |
-
transition: opacity 0.5s ease, visibility 0.5s ease;
|
| 606 |
-
}
|
| 607 |
-
|
| 608 |
-
.shutdown-screen.visible {
|
| 609 |
-
opacity: 1;
|
| 610 |
-
visibility: visible;
|
| 611 |
-
}
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
/* Login and TOTP styling */
|
| 615 |
-
.login-box, .totp-box {
|
| 616 |
-
margin-top: 24px;
|
| 617 |
-
padding: 20px;
|
| 618 |
-
border-radius: 12px;
|
| 619 |
-
background: rgba(255, 255, 255, 0.03);
|
| 620 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 621 |
-
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
| 622 |
-
backdrop-filter: blur(5px);
|
| 623 |
-
-webkit-backdrop-filter: blur(5px);
|
| 624 |
-
animation: splashFadeIn 0.5s ease-out;
|
| 625 |
-
min-width: 280px;
|
| 626 |
-
}
|
| 627 |
-
.login-box h3, .totp-box h3 {
|
| 628 |
-
margin-top: 0;
|
| 629 |
-
margin-bottom: 12px;
|
| 630 |
-
color: #fff;
|
| 631 |
-
font-size: 18px;
|
| 632 |
-
}
|
| 633 |
-
.login-row {
|
| 634 |
-
margin-bottom: 10px;
|
| 635 |
-
}
|
| 636 |
-
.login-row input {
|
| 637 |
-
width: 100%;
|
| 638 |
-
padding: 8px;
|
| 639 |
-
border-radius: 6px;
|
| 640 |
-
border: 1px solid rgba(255, 255, 255, 0.15);
|
| 641 |
-
background: rgba(255, 255, 255, 0.07);
|
| 642 |
-
color: #ffffff;
|
| 643 |
-
font-size: 14px;
|
| 644 |
-
outline: none;
|
| 645 |
-
}
|
| 646 |
-
.login-row input:focus {
|
| 647 |
-
border-color: #3b82c4;
|
| 648 |
-
}
|
| 649 |
-
.login-box button, .totp-box button {
|
| 650 |
-
width: 100%;
|
| 651 |
-
padding: 8px;
|
| 652 |
-
border-radius: 6px;
|
| 653 |
-
background: var(--live);
|
| 654 |
-
color: #ffffff;
|
| 655 |
-
border: none;
|
| 656 |
-
cursor: pointer;
|
| 657 |
-
font-weight: 700;
|
| 658 |
-
font-size: 14px;
|
| 659 |
-
}
|
| 660 |
-
.login-err {
|
| 661 |
-
color: var(--loss);
|
| 662 |
-
font-size: 12px;
|
| 663 |
-
margin: 10px 0 0 0;
|
| 664 |
-
font-weight: bold;
|
| 665 |
-
}
|
| 666 |
-
.totp-qr-container img {
|
| 667 |
-
border: 1px solid #fff;
|
| 668 |
-
border-radius: 6px;
|
| 669 |
-
background: white;
|
| 670 |
-
padding: 6px;
|
| 671 |
-
margin: 8px 0;
|
| 672 |
-
max-width: 140px;
|
| 673 |
-
}
|
| 674 |
-
|
| 675 |
-
/* Admin Panel modal customization & resizability */
|
| 676 |
-
.modal.admin-panel {
|
| 677 |
-
width: min(1200px, 96vw);
|
| 678 |
-
min-width: 600px;
|
| 679 |
-
max-width: 98vw;
|
| 680 |
-
height: auto;
|
| 681 |
-
min-height: 480px;
|
| 682 |
-
max-height: 94vh;
|
| 683 |
-
resize: both;
|
| 684 |
-
overflow: auto;
|
| 685 |
-
box-sizing: border-box;
|
| 686 |
-
}
|
| 687 |
-
|
| 688 |
-
.admin-main-grid {
|
| 689 |
-
display: grid;
|
| 690 |
-
grid-template-columns: 1fr;
|
| 691 |
-
gap: 16px;
|
| 692 |
-
width: 100%;
|
| 693 |
-
}
|
| 694 |
-
|
| 695 |
-
.admin-main-grid.has-sidebar {
|
| 696 |
-
grid-template-columns: minmax(0, 1fr) 320px;
|
| 697 |
-
}
|
| 698 |
-
|
| 699 |
-
.admin-section.user-mgmt {
|
| 700 |
-
display: flex;
|
| 701 |
-
flex-direction: column;
|
| 702 |
-
width: 100%;
|
| 703 |
-
flex: 1;
|
| 704 |
-
}
|
| 705 |
-
|
| 706 |
-
.admin-section.user-mgmt .table-wrap {
|
| 707 |
-
flex: 1;
|
| 708 |
-
max-height: 60vh;
|
| 709 |
-
overflow-y: auto;
|
| 710 |
-
}
|
| 711 |
-
|
| 712 |
-
.admin-table th, .admin-table td {
|
| 713 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
| 714 |
-
}
|
| 715 |
-
.admin-table tr:hover td {
|
| 716 |
-
background: rgba(255, 255, 255, 0.02);
|
| 717 |
-
}
|
| 718 |
-
.sidebar-panel {
|
| 719 |
-
animation: splashFadeIn 0.3s ease-out;
|
| 720 |
-
}
|
| 721 |
-
.chat-view {
|
| 722 |
-
background: #0f172a;
|
| 723 |
-
border: 1px solid rgba(255,255,255,0.1);
|
| 724 |
-
height: 120px;
|
| 725 |
-
overflow-y: auto;
|
| 726 |
-
border-radius: 4px;
|
| 727 |
-
padding: 6px;
|
| 728 |
-
}
|
| 729 |
-
.chat-msg {
|
| 730 |
-
margin-bottom: 4px;
|
| 731 |
-
}
|
| 732 |
-
.chat-msg.system {
|
| 733 |
-
color: #94a3b8;
|
| 734 |
-
font-style: italic;
|
| 735 |
-
}
|
| 736 |
-
.chat-msg.admin {
|
| 737 |
-
color: #3b82f6;
|
| 738 |
-
font-weight: bold;
|
| 739 |
-
}
|
| 740 |
-
.chat-msg.user {
|
| 741 |
-
color: #10b981;
|
| 742 |
-
font-weight: bold;
|
| 743 |
-
}
|
| 744 |
-
|
| 745 |
-
/* ==========================================
|
| 746 |
-
Responsive UI Overrides for Mobile Screens
|
| 747 |
-
========================================== */
|
| 748 |
-
html, body {
|
| 749 |
-
overflow-x: hidden;
|
| 750 |
-
}
|
| 751 |
-
|
| 752 |
-
@media (max-width: 768px) {
|
| 753 |
-
/* Swipeable Horizontal Menu Bar */
|
| 754 |
-
.menubar {
|
| 755 |
-
overflow-x: auto;
|
| 756 |
-
white-space: nowrap;
|
| 757 |
-
flex-wrap: nowrap;
|
| 758 |
-
-webkit-overflow-scrolling: touch;
|
| 759 |
-
gap: 0;
|
| 760 |
-
}
|
| 761 |
-
.menubar .menu {
|
| 762 |
-
flex-shrink: 0;
|
| 763 |
-
}
|
| 764 |
-
.menu-user-display {
|
| 765 |
-
margin-left: 8px !important;
|
| 766 |
-
flex-shrink: 0;
|
| 767 |
-
}
|
| 768 |
-
.menu-btn {
|
| 769 |
-
padding: 8px 10px;
|
| 770 |
-
}
|
| 771 |
-
|
| 772 |
-
/* Responsive Admin Control Panel */
|
| 773 |
-
.stat-cards {
|
| 774 |
-
grid-template-columns: 1fr !important;
|
| 775 |
-
gap: 8px;
|
| 776 |
-
}
|
| 777 |
-
.admin-main-grid {
|
| 778 |
-
grid-template-columns: 1fr !important;
|
| 779 |
-
gap: 16px;
|
| 780 |
-
}
|
| 781 |
-
.table-wrap {
|
| 782 |
-
overflow-x: auto;
|
| 783 |
-
-webkit-overflow-scrolling: touch;
|
| 784 |
-
}
|
| 785 |
-
.admin-table {
|
| 786 |
-
min-width: 550px;
|
| 787 |
-
}
|
| 788 |
-
}
|
| 789 |
-
|
| 790 |
-
@media (max-width: 600px) {
|
| 791 |
-
/* Toolbar Layout Adjustments */
|
| 792 |
-
.toolbar {
|
| 793 |
-
padding: 8px;
|
| 794 |
-
gap: 6px;
|
| 795 |
-
}
|
| 796 |
-
.toolbar > button, .toolbar > label.toggle {
|
| 797 |
-
flex: 1 1 calc(50% - 6px);
|
| 798 |
-
}
|
| 799 |
-
.pf-summary {
|
| 800 |
-
width: 100%;
|
| 801 |
-
order: 4;
|
| 802 |
-
gap: 8px;
|
| 803 |
-
justify-content: space-around;
|
| 804 |
-
padding: 6px;
|
| 805 |
-
}
|
| 806 |
-
.pf-summary span {
|
| 807 |
-
font-size: 11px;
|
| 808 |
-
}
|
| 809 |
-
.datewrap {
|
| 810 |
-
width: 100%;
|
| 811 |
-
order: 5;
|
| 812 |
-
margin-left: 0;
|
| 813 |
-
display: flex;
|
| 814 |
-
justify-content: space-between;
|
| 815 |
-
align-items: center;
|
| 816 |
-
}
|
| 817 |
-
.datewrap input {
|
| 818 |
-
flex-grow: 1;
|
| 819 |
-
max-width: 70%;
|
| 820 |
-
}
|
| 821 |
-
|
| 822 |
-
/* Swipeable Bottom Control Dock */
|
| 823 |
-
.controls {
|
| 824 |
-
overflow-x: auto;
|
| 825 |
-
white-space: nowrap;
|
| 826 |
-
flex-wrap: nowrap;
|
| 827 |
-
-webkit-overflow-scrolling: touch;
|
| 828 |
-
gap: 6px;
|
| 829 |
-
padding: 6px 8px;
|
| 830 |
-
}
|
| 831 |
-
.controls button {
|
| 832 |
-
flex-shrink: 0;
|
| 833 |
-
}
|
| 834 |
-
}
|
| 835 |
-
|
| 836 |
-
@media (max-width: 480px) {
|
| 837 |
-
/* Add Stock Row Stacking */
|
| 838 |
-
.addrow {
|
| 839 |
-
flex-direction: column;
|
| 840 |
-
align-items: stretch;
|
| 841 |
-
gap: 6px;
|
| 842 |
-
padding: 8px;
|
| 843 |
-
}
|
| 844 |
-
#stock-search {
|
| 845 |
-
width: 100%;
|
| 846 |
-
flex: unset;
|
| 847 |
-
}
|
| 848 |
-
.addrow button {
|
| 849 |
-
width: 100%;
|
| 850 |
-
}
|
| 851 |
-
|
| 852 |
-
/* High Density Grid and Card scaling */
|
| 853 |
-
.grid {
|
| 854 |
-
grid-template-columns: 1fr;
|
| 855 |
-
padding: 0 8px 80px;
|
| 856 |
-
gap: 10px;
|
| 857 |
-
}
|
| 858 |
-
.card {
|
| 859 |
-
padding: 8px;
|
| 860 |
-
}
|
| 861 |
-
.card h4 {
|
| 862 |
-
font-size: 13px;
|
| 863 |
-
}
|
| 864 |
-
|
| 865 |
-
/* Responsive Dialog Sizing */
|
| 866 |
-
.modal {
|
| 867 |
-
padding: 14px;
|
| 868 |
-
min-width: unset;
|
| 869 |
-
width: 100%;
|
| 870 |
-
}
|
| 871 |
-
}
|
| 872 |
-
|
| 873 |
-
/* Screen reader only helper */
|
| 874 |
-
.sr-only {
|
| 875 |
-
position: absolute;
|
| 876 |
-
width: 1px;
|
| 877 |
-
height: 1px;
|
| 878 |
-
padding: 0;
|
| 879 |
-
margin: -1px;
|
| 880 |
-
overflow: hidden;
|
| 881 |
-
clip: rect(0, 0, 0, 0);
|
| 882 |
-
white-space: nowrap;
|
| 883 |
-
border: 0;
|
| 884 |
-
}
|
| 885 |
-
|
| 886 |
-
/* AI button & modal styles */
|
| 887 |
-
.ai-btn {
|
| 888 |
-
background: linear-gradient(135deg, #6c5ce7, #4834d4);
|
| 889 |
-
color: #fff;
|
| 890 |
-
border: none;
|
| 891 |
-
font-weight: 600;
|
| 892 |
-
transition: transform 0.2s ease, filter 0.2s ease;
|
| 893 |
-
}
|
| 894 |
-
.ai-btn:hover {
|
| 895 |
-
filter: brightness(1.2);
|
| 896 |
-
transform: translateY(-1px);
|
| 897 |
-
}
|
| 898 |
-
.ai-analysis-modal {
|
| 899 |
-
max-width: 800px;
|
| 900 |
-
width: 90%;
|
| 901 |
-
}
|
| 902 |
-
@keyframes spin {
|
| 903 |
-
0% { transform: rotate(0deg); }
|
| 904 |
-
100% { transform: rotate(360deg); }
|
| 905 |
-
}
|
| 906 |
-
/* Style markdown lists, headings, and tables in modal */
|
| 907 |
-
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
| 908 |
-
margin-top: 16px;
|
| 909 |
-
margin-bottom: 8px;
|
| 910 |
-
color: var(--accent);
|
| 911 |
-
}
|
| 912 |
-
.markdown-body p {
|
| 913 |
-
margin-bottom: 12px;
|
| 914 |
-
}
|
| 915 |
-
.markdown-body ul, .markdown-body ol {
|
| 916 |
-
padding-left: 20px;
|
| 917 |
-
margin-bottom: 12px;
|
| 918 |
-
}
|
| 919 |
-
.markdown-body li {
|
| 920 |
-
margin-bottom: 4px;
|
| 921 |
-
}
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash/web/templates/index.html
DELETED
|
@@ -1,489 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<title>NSE Stock Dashboard</title>
|
| 7 |
-
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
| 8 |
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
| 9 |
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/11.1.1/marked.min.js"></script>
|
| 10 |
-
</head>
|
| 11 |
-
<body>
|
| 12 |
-
|
| 13 |
-
<!-- Accessibility Landmark Header -->
|
| 14 |
-
<h1 class="sr-only">NSE Stock Dashboard</h1>
|
| 15 |
-
|
| 16 |
-
<!-- Splash Screen -->
|
| 17 |
-
<div id="splash-screen" class="splash-screen">
|
| 18 |
-
<div class="splash-content">
|
| 19 |
-
<div class="splash-logo">
|
| 20 |
-
<span class="splash-brand-nse">NSE</span><span class="splash-brand-dash">Dashboard</span>
|
| 21 |
-
</div>
|
| 22 |
-
<div class="splash-subtitle">Market Intelligence & Analytics</div>
|
| 23 |
-
<div class="splash-loader-container">
|
| 24 |
-
<div class="splash-loader-bar"></div>
|
| 25 |
-
</div>
|
| 26 |
-
<div class="splash-status" id="splash-status">Initializing System...</div>
|
| 27 |
-
|
| 28 |
-
<!-- Login Form Box -->
|
| 29 |
-
<div id="login-box" class="login-box hidden">
|
| 30 |
-
<h3>Sign In</h3>
|
| 31 |
-
<div class="login-row">
|
| 32 |
-
<input type="text" id="login-username" placeholder="Username" autocomplete="username" aria-label="Username">
|
| 33 |
-
</div>
|
| 34 |
-
<div class="login-row">
|
| 35 |
-
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" aria-label="Password">
|
| 36 |
-
</div>
|
| 37 |
-
<div class="login-row">
|
| 38 |
-
<button id="btn-login-submit">Login</button>
|
| 39 |
-
</div>
|
| 40 |
-
<p id="login-error" class="login-err hidden"></p>
|
| 41 |
-
</div>
|
| 42 |
-
|
| 43 |
-
<!-- TOTP Form Box -->
|
| 44 |
-
<div id="totp-box" class="totp-box hidden">
|
| 45 |
-
<h3>Two-Factor Authentication</h3>
|
| 46 |
-
<div id="totp-qr-container" class="totp-qr-container hidden">
|
| 47 |
-
<p style="font-size: 12px; margin-bottom: 10px; color: #a5b4fc;">Scan the QR code below using your Google Authenticator or choice TOTP app:</p>
|
| 48 |
-
<img id="totp-qr-image" src="" alt="TOTP QR Code">
|
| 49 |
-
<p style="font-size: 11px; margin-top: 5px; color: #cbd5e1;">Secret key: <code id="totp-secret-key" style="font-weight: bold; letter-spacing: 0.5px;"></code></p>
|
| 50 |
-
</div>
|
| 51 |
-
<p>Enter the 6-digit verification code from your authenticator app:</p>
|
| 52 |
-
<div class="captcha-row">
|
| 53 |
-
<input type="number" id="totp-input" placeholder="000000" pattern="[0-9]*" inputmode="numeric" aria-label="6-digit TOTP verification code">
|
| 54 |
-
<button id="btn-totp-submit">Verify</button>
|
| 55 |
-
</div>
|
| 56 |
-
<p id="totp-error" class="login-err hidden"></p>
|
| 57 |
-
</div>
|
| 58 |
-
</div>
|
| 59 |
-
</div>
|
| 60 |
-
|
| 61 |
-
<!-- Status banner: green = live, red = historic (v4.5 parity) -->
|
| 62 |
-
<div id="banner" class="banner historic">
|
| 63 |
-
<div style="display: flex; align-items: center; gap: 8px;">
|
| 64 |
-
<span id="banner-text">Historic Data</span>
|
| 65 |
-
<span id="env-pill" class="env-pill local">LOCAL</span>
|
| 66 |
-
</div>
|
| 67 |
-
<span id="banner-watchlist"></span>
|
| 68 |
-
</div>
|
| 69 |
-
|
| 70 |
-
<!-- Menu bar -->
|
| 71 |
-
<nav class="menubar" role="menubar" aria-label="Main Navigation">
|
| 72 |
-
<div class="menu" data-menu="watchlists">
|
| 73 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-watchlists" style="color: #10b981; font-weight: bold;">Watchlists 🟢</button>
|
| 74 |
-
<div class="menu-drop" id="menu-watchlists" role="menu" aria-labelledby="menu-btn-watchlists"></div>
|
| 75 |
-
</div>
|
| 76 |
-
<div class="menu" data-menu="maxmin">
|
| 77 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-maxmin">Max Min Analysis</button>
|
| 78 |
-
<div class="menu-drop" id="menu-maxmin" role="menu" aria-labelledby="menu-btn-maxmin"></div>
|
| 79 |
-
</div>
|
| 80 |
-
<div class="menu" data-menu="volume">
|
| 81 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-volume">Volume Analysis</button>
|
| 82 |
-
<div class="menu-drop" id="menu-volume" role="menu" aria-labelledby="menu-btn-volume"></div>
|
| 83 |
-
</div>
|
| 84 |
-
<div class="menu" data-menu="liveanalysis">
|
| 85 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-liveanalysis">Live Analysis</button>
|
| 86 |
-
<div class="menu-drop" id="menu-liveanalysis" role="menu" aria-labelledby="menu-btn-liveanalysis"></div>
|
| 87 |
-
</div>
|
| 88 |
-
<div class="menu" data-menu="simulation">
|
| 89 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-simulation">Simulation</button>
|
| 90 |
-
<div class="menu-drop" id="menu-simulation" role="menu" aria-labelledby="menu-btn-simulation"></div>
|
| 91 |
-
</div>
|
| 92 |
-
<div class="menu" data-menu="settings">
|
| 93 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-settings" style="color: #38bdf8;">Settings ⚙️</button>
|
| 94 |
-
<div class="menu-drop" id="menu-settings" role="menu" aria-labelledby="menu-btn-settings">
|
| 95 |
-
<button class="menu-drop-item" id="btn-settings-change-password" role="menuitem">🔒 Change Password</button>
|
| 96 |
-
</div>
|
| 97 |
-
</div>
|
| 98 |
-
<div class="menu hidden" id="menu-utilities-container" data-menu="utilities">
|
| 99 |
-
<button class="menu-btn" aria-haspopup="true" aria-expanded="false" id="menu-btn-utilities">Utilities</button>
|
| 100 |
-
<div class="menu-drop" id="menu-utilities" role="menu" aria-labelledby="menu-btn-utilities"></div>
|
| 101 |
-
</div>
|
| 102 |
-
<div class="menu-llm-select-wrap" style="margin-left: auto; display: flex; align-items: center; gap: 10px; padding-right: 15px; font-size: 12px;">
|
| 103 |
-
<label for="select-primary-llm" style="color: #94a3b8; font-weight: 500; display: flex; align-items: center; gap: 5px;">
|
| 104 |
-
<span style="color: #3b82f6; font-weight: bold;">🤖 Primary LLM:</span>
|
| 105 |
-
<select id="select-primary-llm" aria-label="Primary LLM Model Selection" style="background: #0f172a; color: #60a5fa; border: 1px solid #3b82f6; border-radius: 4px; padding: 3px 8px; font-size: 11px; font-weight: bold; cursor: pointer; outline: none;">
|
| 106 |
-
<option value="google/diffusiongemma-26b-a4b-it" selected>DiffusionGemma 26B (google/diffusiongemma-26b-a4b-it) [Fastest]</option>
|
| 107 |
-
<option value="minimaxai/minimax-m3">Minimax-M3 (minimaxai/minimax-m3)</option>
|
| 108 |
-
<option value="z-ai/glm-5.2">GLM-5.2 (z-ai/glm-5.2)</option>
|
| 109 |
-
<option value="nvidia/nemotron-3-ultra-550b-a55b">Nemotron 3 Ultra 550B (nvidia/nemotron-3-ultra-550b-a55b)</option>
|
| 110 |
-
<option value="meta/llama-3.1-70b-instruct">Llama 3.1 70B (meta/llama-3.1-70b-instruct)</option>
|
| 111 |
-
<option value="thinkingmachines/inkling">Inkling 975B (thinkingmachines/inkling)</option>
|
| 112 |
-
</select>
|
| 113 |
-
</label>
|
| 114 |
-
|
| 115 |
-
<label for="select-llm-timeout" style="color: #94a3b8; font-weight: 500; display: flex; align-items: center; gap: 5px;">
|
| 116 |
-
<span style="color: #10b981; font-weight: bold;">⏱️ Timeout:</span>
|
| 117 |
-
<select id="select-llm-timeout" aria-label="LLM Timeout Selection" style="background: #0f172a; color: #10b981; border: 1px solid #10b981; border-radius: 4px; padding: 3px 6px; font-size: 11px; font-weight: bold; cursor: pointer; outline: none;">
|
| 118 |
-
<option value="5" selected>5 Mins</option>
|
| 119 |
-
<option value="10">10 Mins</option>
|
| 120 |
-
<option value="15">15 Mins</option>
|
| 121 |
-
<option value="20">20 Mins</option>
|
| 122 |
-
</select>
|
| 123 |
-
</label>
|
| 124 |
-
</div>
|
| 125 |
-
<div class="menu-user-display" id="user-display-wrap" style="display: flex; align-items: center; gap: 12px; padding-right: 15px;">
|
| 126 |
-
<span style="font-size: 13px; color: #94a3b8;">User: <b id="display-username" style="color: #3b82f6;">admin</b></span>
|
| 127 |
-
<button id="btn-logout" style="background: none; border: 1px solid #475569; color: #94a3b8; padding: 3px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.color='#ef4444'; this.style.borderColor='#ef4444'" onmouseout="this.style.color='#94a3b8'; this.style.borderColor='#475569'">Logout</button>
|
| 128 |
-
</div>
|
| 129 |
-
<div class="menu" data-menu="exit">
|
| 130 |
-
<button class="menu-btn" id="btn-exit" aria-label="Exit Application" style="color: #e07b70; font-weight: bold;">Exit</button>
|
| 131 |
-
</div>
|
| 132 |
-
</nav>
|
| 133 |
-
|
| 134 |
-
<!-- Toolbar -->
|
| 135 |
-
<header class="toolbar">
|
| 136 |
-
<label class="toggle">
|
| 137 |
-
<input type="checkbox" id="live-toggle">
|
| 138 |
-
<span>Live Data</span>
|
| 139 |
-
</label>
|
| 140 |
-
<button id="btn-portfolio">Load Portfolio</button>
|
| 141 |
-
<div id="portfolio-summary" class="pf-summary">
|
| 142 |
-
<span>Invested <b id="pf-invested">0.00</b></span>
|
| 143 |
-
<span>Current <b id="pf-current">0.00</b></span>
|
| 144 |
-
<span>Day's Gain <b id="pf-day">0.00 (0.00%)</b></span>
|
| 145 |
-
<span>Abs Returns <b id="pf-abs">0.00 (0.00%)</b></span>
|
| 146 |
-
</div>
|
| 147 |
-
<button id="btn-export">Export CSV</button>
|
| 148 |
-
<button id="btn-sensex">SENSEX</button>
|
| 149 |
-
<button id="btn-nifty">NIFTY</button>
|
| 150 |
-
|
| 151 |
-
<!-- Live Indices Display Cards (NIFTY & SENSEX) -->
|
| 152 |
-
<div id="index-tickers" class="index-tickers">
|
| 153 |
-
<div class="index-badge" id="nifty-badge" title="Live NIFTY 50 (Click to filter)">
|
| 154 |
-
<span class="idx-name">NIFTY</span>
|
| 155 |
-
<span class="idx-pill positive" id="nifty-val">▲ 24,227.50 (+242.15 / +1.01%)</span>
|
| 156 |
-
</div>
|
| 157 |
-
<div class="index-badge" id="sensex-badge" title="Live SENSEX (Click to filter)">
|
| 158 |
-
<span class="idx-name">SENSEX</span>
|
| 159 |
-
<span class="idx-pill positive" id="sensex-val">▲ 77,568.70 (+802.78 / +1.04%)</span>
|
| 160 |
-
</div>
|
| 161 |
-
</div>
|
| 162 |
-
|
| 163 |
-
<!-- RTC Clock (IST) & Historic Data Date -->
|
| 164 |
-
<div class="date-rtc-wrap">
|
| 165 |
-
<div id="rtc-clock" class="rtc-clock" title="Current Real-Time Clock (IST Time Zone)">
|
| 166 |
-
<span class="rtc-icon">🕒</span> <span id="rtc-time">--:--:-- IST</span>
|
| 167 |
-
</div>
|
| 168 |
-
<label class="datewrap">Historic data date
|
| 169 |
-
<input type="date" id="date-input">
|
| 170 |
-
</label>
|
| 171 |
-
</div>
|
| 172 |
-
</header>
|
| 173 |
-
|
| 174 |
-
<!-- Add stock row -->
|
| 175 |
-
<section class="addrow">
|
| 176 |
-
<input list="stock-list" id="stock-search"
|
| 177 |
-
placeholder="Search symbol or security…" aria-label="Search stock symbol or security to add">
|
| 178 |
-
<datalist id="stock-list"></datalist>
|
| 179 |
-
<button id="btn-add">Add Stock</button>
|
| 180 |
-
<span id="board-count" class="muted"></span>
|
| 181 |
-
</section>
|
| 182 |
-
|
| 183 |
-
<!-- Card grid -->
|
| 184 |
-
<main id="grid" class="grid"></main>
|
| 185 |
-
|
| 186 |
-
<!-- Bottom controls -->
|
| 187 |
-
<footer class="controls">
|
| 188 |
-
<button id="btn-refresh">Refresh Data</button>
|
| 189 |
-
<button id="btn-save">Save Dashboard</button>
|
| 190 |
-
<button id="btn-saveas">Save as Watchlist</button>
|
| 191 |
-
<button id="btn-add-selected">Add Selected to Watchlist</button>
|
| 192 |
-
<button id="btn-delete-all" class="danger">Delete All</button>
|
| 193 |
-
</footer>
|
| 194 |
-
|
| 195 |
-
<!-- Busy overlay (web equivalent of the v4.5 wait dialog) -->
|
| 196 |
-
<div id="busy" class="overlay hidden">
|
| 197 |
-
<div class="busy-box">
|
| 198 |
-
<div class="spinner"></div>
|
| 199 |
-
<div id="busy-text">Processing…</div>
|
| 200 |
-
</div>
|
| 201 |
-
</div>
|
| 202 |
-
|
| 203 |
-
<!-- Live source modal -->
|
| 204 |
-
<div id="modal-live" class="overlay hidden">
|
| 205 |
-
<div class="modal">
|
| 206 |
-
<h3>Start live data</h3>
|
| 207 |
-
<label>Source
|
| 208 |
-
<select id="live-source">
|
| 209 |
-
<option value="Google">Google Finance</option>
|
| 210 |
-
<option value="Yahoo">Yahoo Finance</option>
|
| 211 |
-
</select>
|
| 212 |
-
</label>
|
| 213 |
-
<label>Refresh every (minutes)
|
| 214 |
-
<input type="number" id="live-rate" value="5" min="1">
|
| 215 |
-
</label>
|
| 216 |
-
<div class="modal-actions">
|
| 217 |
-
<button id="live-cancel">Cancel</button>
|
| 218 |
-
<button id="live-ok" class="primary">Start</button>
|
| 219 |
-
</div>
|
| 220 |
-
</div>
|
| 221 |
-
</div>
|
| 222 |
-
|
| 223 |
-
<!-- Save-as modal -->
|
| 224 |
-
<div id="modal-saveas" class="overlay hidden">
|
| 225 |
-
<div class="modal">
|
| 226 |
-
<h3>Save as watchlist</h3>
|
| 227 |
-
<label>Slot (1–20)
|
| 228 |
-
<input type="number" id="saveas-slot" value="1" min="1" max="20">
|
| 229 |
-
</label>
|
| 230 |
-
<label>Name
|
| 231 |
-
<input type="text" id="saveas-name" placeholder="Watchlist name">
|
| 232 |
-
</label>
|
| 233 |
-
<div class="modal-actions">
|
| 234 |
-
<button id="saveas-cancel">Cancel</button>
|
| 235 |
-
<button id="saveas-ok" class="primary">Save</button>
|
| 236 |
-
</div>
|
| 237 |
-
</div>
|
| 238 |
-
</div>
|
| 239 |
-
|
| 240 |
-
<!-- Admin Panel Modal -->
|
| 241 |
-
<div id="modal-admin" class="overlay hidden">
|
| 242 |
-
<div class="modal admin-panel">
|
| 243 |
-
<div class="modal-header" style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; margin-bottom: 15px;">
|
| 244 |
-
<h3 style="margin: 0; display: flex; align-items: center; gap: 8px;">🛡️ Admin Control Panel</h3>
|
| 245 |
-
<button class="close-btn" id="admin-close" style="background: none; border: none; font-size: 24px; color: #94a3b8; cursor: pointer;">×</button>
|
| 246 |
-
</div>
|
| 247 |
-
|
| 248 |
-
<div class="admin-content">
|
| 249 |
-
<div class="stat-cards" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 18px;">
|
| 250 |
-
<div class="stat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 8px; padding: 12px; text-align: center;">
|
| 251 |
-
<h4 style="margin: 0 0 5px 0; color: #3b82f6; font-size: 20px;" id="stat-users">0</h4>
|
| 252 |
-
<span style="font-size: 11px; color: #64748b; text-transform: uppercase;">Total Users</span>
|
| 253 |
-
</div>
|
| 254 |
-
<div class="stat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 8px; padding: 12px; text-align: center;">
|
| 255 |
-
<h4 style="margin: 0 0 5px 0; color: #10b981; font-size: 20px;" id="stat-symbols">0</h4>
|
| 256 |
-
<span style="font-size: 11px; color: #64748b; text-transform: uppercase;">Total Symbols</span>
|
| 257 |
-
</div>
|
| 258 |
-
<div class="stat-card" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07); border-radius: 8px; padding: 12px; text-align: center;">
|
| 259 |
-
<h4 style="margin: 0 0 5px 0; color: #f59e0b; font-size: 20px;" id="stat-yfinance">Checking...</h4>
|
| 260 |
-
<span style="font-size: 11px; color: #64748b; text-transform: uppercase;">yFinance Status</span>
|
| 261 |
-
</div>
|
| 262 |
-
</div>
|
| 263 |
-
|
| 264 |
-
<div class="admin-main-grid">
|
| 265 |
-
<!-- User Registry Section -->
|
| 266 |
-
<div class="admin-section user-mgmt" style="background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.05); border-radius: 10px; padding: 15px;">
|
| 267 |
-
<div class="section-header" style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
| 268 |
-
<h4 style="margin: 0; font-size: 14px; text-transform: uppercase; color: #cbd5e1;">User Registry</h4>
|
| 269 |
-
<button id="admin-btn-show-add" class="primary btn-xs" style="padding: 4px 10px; font-size: 11px;">➕ Add User</button>
|
| 270 |
-
</div>
|
| 271 |
-
<div class="table-wrap" style="max-height: 300px; overflow-y: auto;">
|
| 272 |
-
<table class="admin-table" style="width: 100%; border-collapse: collapse; text-align: left; font-size: 12px;">
|
| 273 |
-
<thead>
|
| 274 |
-
<tr style="border-bottom: 1px solid rgba(255,255,255,0.1); color: #94a3b8;">
|
| 275 |
-
<th style="padding: 6px;">User ID</th>
|
| 276 |
-
<th style="padding: 6px;">Status</th>
|
| 277 |
-
<th style="padding: 6px;">Role</th>
|
| 278 |
-
<th style="padding: 6px;">Logins</th>
|
| 279 |
-
<th style="padding: 6px;">Last Login</th>
|
| 280 |
-
<th style="padding: 6px;">Account</th>
|
| 281 |
-
<th style="padding: 6px; text-align: right;">Actions</th>
|
| 282 |
-
</tr>
|
| 283 |
-
</thead>
|
| 284 |
-
<tbody id="admin-user-list"></tbody>
|
| 285 |
-
</table>
|
| 286 |
-
</div>
|
| 287 |
-
</div>
|
| 288 |
-
|
| 289 |
-
<!-- Sidebar detail panels -->
|
| 290 |
-
<div class="admin-sidebar" style="display: flex; flex-direction: column; gap: 12px;">
|
| 291 |
-
<!-- Add User Panel -->
|
| 292 |
-
<div id="panel-add-user" class="sidebar-panel hidden" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px;">
|
| 293 |
-
<h4 style="margin: 0 0 10px 0; font-size: 13px; color: #cbd5e1; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">Create New User</h4>
|
| 294 |
-
<div style="display: flex; flex-direction: column; gap: 8px;">
|
| 295 |
-
<input type="text" id="add-username" placeholder="Username" aria-label="New username" style="width: 100%; padding: 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.15); background: #0f172a; color: white;">
|
| 296 |
-
<select id="add-role" aria-label="New user role" style="width: 100%; padding: 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.15); background: #0f172a; color: white;">
|
| 297 |
-
<option value="user">User</option>
|
| 298 |
-
<option value="admin">Admin</option>
|
| 299 |
-
</select>
|
| 300 |
-
<button id="admin-btn-create-submit" class="primary" style="width: 100%; padding: 6px; border-radius: 4px;">Create User</button>
|
| 301 |
-
</div>
|
| 302 |
-
</div>
|
| 303 |
-
|
| 304 |
-
<!-- TOTP QR Code Panel -->
|
| 305 |
-
<div id="panel-qr" class="sidebar-panel hidden" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px; text-align: center;">
|
| 306 |
-
<h4 style="margin: 0 0 10px 0; font-size: 13px; color: #cbd5e1; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px; text-align: left;">TOTP Authenticator Setup</h4>
|
| 307 |
-
<div id="admin-qr-display" style="margin: 10px 0; display: inline-block; padding: 8px; background: white; border-radius: 6px;"></div>
|
| 308 |
-
<div style="font-size: 11px; margin-top: 8px; text-align: left; color: #94a3b8; word-break: break-all;">
|
| 309 |
-
Secret key: <code id="admin-secret-key" style="font-weight: bold; color: #3b82f6;"></code>
|
| 310 |
-
</div>
|
| 311 |
-
</div>
|
| 312 |
-
|
| 313 |
-
<!-- User Metrics Panel -->
|
| 314 |
-
<div id="panel-metrics" class="sidebar-panel hidden" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px; font-size: 12px;">
|
| 315 |
-
<h4 style="margin: 0 0 10px 0; font-size: 13px; color: #cbd5e1; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">User Statistics</h4>
|
| 316 |
-
<div id="admin-metrics-display" style="display: flex; flex-direction: column; gap: 6px; color: #cbd5e1;"></div>
|
| 317 |
-
</div>
|
| 318 |
-
|
| 319 |
-
<!-- Mirrored Activity Logs Panel -->
|
| 320 |
-
<div id="panel-mirror" class="sidebar-panel hidden" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px;">
|
| 321 |
-
<h4 style="margin: 0 0 10px 0; font-size: 13px; color: #cbd5e1; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">User Activity Mirror</h4>
|
| 322 |
-
<pre id="admin-mirror-display" class="log-view" style="max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 11px; color: #a7f3d0; margin: 0; background: #0f172a; padding: 8px; border-radius: 4px; text-align: left; white-space: pre-wrap;"></pre>
|
| 323 |
-
</div>
|
| 324 |
-
|
| 325 |
-
<!-- User Chat Panel -->
|
| 326 |
-
<div id="panel-chat" class="sidebar-panel hidden" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px;">
|
| 327 |
-
<h4 id="chat-user-title" style="margin: 0 0 10px 0; font-size: 13px; color: #cbd5e1; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">Chat</h4>
|
| 328 |
-
<div id="admin-chat-display" class="chat-view" style="max-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; font-size: 11px; margin-bottom: 8px; background: #0f172a; padding: 6px; border-radius: 4px;"></div>
|
| 329 |
-
<div class="chat-input-row" style="display: flex; gap: 6px;">
|
| 330 |
-
<input type="text" id="chat-input" placeholder="Type message..." aria-label="Chat message text" style="flex: 1; padding: 4px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.15); background: #0f172a; color: white; font-size: 11px;">
|
| 331 |
-
<button id="chat-send-btn" class="primary" style="padding: 4px 10px; font-size: 11px; border-radius: 4px;">Send</button>
|
| 332 |
-
</div>
|
| 333 |
-
</div>
|
| 334 |
-
</div>
|
| 335 |
-
</div>
|
| 336 |
-
</div>
|
| 337 |
-
</div>
|
| 338 |
-
</div>
|
| 339 |
-
|
| 340 |
-
<!-- Script terminal modal -->
|
| 341 |
-
<div id="modal-terminal" class="overlay hidden">
|
| 342 |
-
<div class="modal terminal">
|
| 343 |
-
<h3 id="terminal-title">Script output</h3>
|
| 344 |
-
<pre id="terminal-out"></pre>
|
| 345 |
-
<div class="terminal-input">
|
| 346 |
-
<input type="text" id="terminal-in"
|
| 347 |
-
placeholder="Type input and press Enter…">
|
| 348 |
-
<button id="terminal-send">Send</button>
|
| 349 |
-
<button id="terminal-close">Close</button>
|
| 350 |
-
</div>
|
| 351 |
-
</div>
|
| 352 |
-
</div>
|
| 353 |
-
|
| 354 |
-
<!-- AI Analysis Modal -->
|
| 355 |
-
<div id="modal-ai" class="overlay hidden" role="dialog" aria-modal="true" aria-labelledby="ai-title">
|
| 356 |
-
<div class="modal ai-analysis-modal" style="max-width: 850px; width: 90%;">
|
| 357 |
-
<div class="modal-header" style="display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; margin-bottom: 15px;">
|
| 358 |
-
<h3 id="ai-title" style="margin: 0; display: flex; align-items: center; gap: 8px;">🤖 Agentic AI Stock Research</h3>
|
| 359 |
-
<button id="ai-close" class="close-btn" style="background: none; border: none; font-size: 24px; color: var(--muted); cursor: pointer;" aria-label="Close AI analysis">×</button>
|
| 360 |
-
</div>
|
| 361 |
-
|
| 362 |
-
<div id="ai-content" class="markdown-body" style="max-height: 60vh; overflow-y: auto; text-align: left; padding: 12px; font-size: 14px; line-height: 1.6; color: var(--fg); background: rgba(15, 23, 42, 0.6); border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
|
| 363 |
-
<!-- Generated AI analysis in markdown format will go here -->
|
| 364 |
-
</div>
|
| 365 |
-
|
| 366 |
-
<!-- Metadata & Controls Footer -->
|
| 367 |
-
<div class="modal-footer" style="margin-top: 15px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;">
|
| 368 |
-
<!-- Metadata details: LLM Name, Time, Tokens -->
|
| 369 |
-
<div id="ai-meta-bar" style="font-size: 11px; color: #94a3b8; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
| 370 |
-
<span id="ai-meta-model">🤖 LLM: <b style="color: #3b82f6;">z-ai/glm-5.2</b></span>
|
| 371 |
-
<span id="ai-meta-time">⏱️ Time: <b style="color: #10b981;">--s</b></span>
|
| 372 |
-
<span id="ai-meta-tokens">📊 Tokens: <b style="color: #cbd5e1;">In: 0 | Out: 0 | Total: 0</b></span>
|
| 373 |
-
</div>
|
| 374 |
-
|
| 375 |
-
<!-- Action Buttons -->
|
| 376 |
-
<div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
|
| 377 |
-
<button id="ai-refresh-btn" class="primary" style="padding: 6px 12px; font-size: 12px; border-radius: 6px; background: rgba(59, 130, 246, 0.2); border: 1px solid #3b82f6; color: #60a5fa; cursor: pointer; display: flex; align-items: center; gap: 4px;" title="Run fresh background AI agentic analysis">
|
| 378 |
-
🔄 Refresh Inference
|
| 379 |
-
</button>
|
| 380 |
-
<button id="ai-copy-btn" class="primary" style="padding: 6px 12px; font-size: 12px; border-radius: 6px; background: #3b82f6; border: 1px solid #2563eb; color: white; cursor: pointer; display: flex; align-items: center; gap: 4px;" title="Copy full report markdown to clipboard">
|
| 381 |
-
📋 Copy Report
|
| 382 |
-
</button>
|
| 383 |
-
</div>
|
| 384 |
-
</div>
|
| 385 |
-
</div>
|
| 386 |
-
</div>
|
| 387 |
-
|
| 388 |
-
<!-- Floating AI Agent Background Status Widget (Bottom Right) -->
|
| 389 |
-
<div id="ai-bg-widget" class="hidden" style="position: fixed; bottom: 20px; right: 20px; z-index: 9999; background: #0f172a; border: 1px solid #3b82f6; border-radius: 8px; padding: 10px 14px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); display: flex; align-items: center; gap: 10px; font-size: 12px; color: #f8fafc; transition: all 0.3s ease;">
|
| 390 |
-
<div id="ai-bg-spinner" class="spinner" style="border: 2px solid rgba(255,255,255,0.2); border-top: 2px solid #3b82f6; border-radius: 50%; width: 16px; height: 16px; animation: spin 1s linear infinite;"></div>
|
| 391 |
-
<span id="ai-bg-text" style="font-weight: 500;">AI Agentic Research Running...</span>
|
| 392 |
-
<button id="ai-bg-view-btn" style="background: #3b82f6; border: none; color: white; border-radius: 4px; padding: 3px 10px; font-size: 11px; font-weight: bold; cursor: pointer; display: none;">View Report</button>
|
| 393 |
-
</div>
|
| 394 |
-
|
| 395 |
-
<!-- Shutdown Screen -->
|
| 396 |
-
<div id="shutdown-screen" class="shutdown-screen">
|
| 397 |
-
<div class="splash-content">
|
| 398 |
-
<div class="splash-logo">
|
| 399 |
-
<span class="splash-brand-nse">NSE</span><span class="splash-brand-dash">Dashboard</span>
|
| 400 |
-
</div>
|
| 401 |
-
<div class="splash-subtitle" style="color: var(--loss); text-shadow: 0 0 20px rgba(192, 57, 43, 0.3);">System Shut Down</div>
|
| 402 |
-
<div class="splash-status" style="font-size: 13px; margin-top: 20px; line-height: 1.6;">
|
| 403 |
-
The backend server has been terminated safely.<br>
|
| 404 |
-
You may now close this browser tab.
|
| 405 |
-
</div>
|
| 406 |
-
</div>
|
| 407 |
-
</div>
|
| 408 |
-
|
| 409 |
-
<!-- Modal: Change Password (Settings Menu) -->
|
| 410 |
-
<div id="modal-change-password" class="modal-overlay hidden" role="dialog" aria-modal="true">
|
| 411 |
-
<div class="modal-card" style="max-width: 440px;">
|
| 412 |
-
<h3>🔒 Change Password</h3>
|
| 413 |
-
<p style="font-size: 12px; color: #94a3b8; margin-bottom: 15px; line-height: 1.4;">
|
| 414 |
-
Enter your current password and your new password twice to reconfirm.
|
| 415 |
-
</p>
|
| 416 |
-
<div style="background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 6px; padding: 10px; margin-bottom: 15px; font-size: 11px; color: #cbd5e1; line-height: 1.5;">
|
| 417 |
-
<b style="color: #60a5fa;">Password Policy Requirements:</b>
|
| 418 |
-
<ul style="margin: 4px 0 0 16px; padding: 0;">
|
| 419 |
-
<li>Minimum 8 characters length</li>
|
| 420 |
-
<li>At least 1 uppercase letter (A-Z)</li>
|
| 421 |
-
<li>At least 1 lowercase letter (a-z)</li>
|
| 422 |
-
<li>At least 1 digit (0-9)</li>
|
| 423 |
-
<li>At least 1 special character (e.g. !@#$%^&*)</li>
|
| 424 |
-
</ul>
|
| 425 |
-
</div>
|
| 426 |
-
<div style="display: flex; flex-direction: column; gap: 10px;">
|
| 427 |
-
<div>
|
| 428 |
-
<label for="cp-current" style="display: block; font-size: 11px; font-weight: bold; color: #94a3b8; margin-bottom: 4px;">Current Password:</label>
|
| 429 |
-
<input type="password" id="cp-current" placeholder="Enter current password" style="width: 100%; box-sizing: border-box;">
|
| 430 |
-
</div>
|
| 431 |
-
<div>
|
| 432 |
-
<label for="cp-new" style="display: block; font-size: 11px; font-weight: bold; color: #94a3b8; margin-bottom: 4px;">New Password (1st Entry):</label>
|
| 433 |
-
<input type="password" id="cp-new" placeholder="Enter new password" style="width: 100%; box-sizing: border-box;">
|
| 434 |
-
</div>
|
| 435 |
-
<div>
|
| 436 |
-
<label for="cp-confirm" style="display: block; font-size: 11px; font-weight: bold; color: #94a3b8; margin-bottom: 4px;">Confirm New Password (2nd Entry):</label>
|
| 437 |
-
<input type="password" id="cp-confirm" placeholder="Re-enter new password" style="width: 100%; box-sizing: border-box;">
|
| 438 |
-
</div>
|
| 439 |
-
</div>
|
| 440 |
-
<p id="cp-error" style="color: #ef4444; font-size: 11px; margin-top: 10px; font-weight: bold;" class="hidden"></p>
|
| 441 |
-
<div class="modal-actions" style="margin-top: 20px;">
|
| 442 |
-
<button id="cp-cancel" class="btn-subtle">Cancel</button>
|
| 443 |
-
<button id="cp-submit" class="primary">Update Password</button>
|
| 444 |
-
</div>
|
| 445 |
-
</div>
|
| 446 |
-
</div>
|
| 447 |
-
|
| 448 |
-
<!-- Modal: Mandatory Password Change on Next Login (Enforced Security Policy) -->
|
| 449 |
-
<div id="modal-force-password-change" class="modal-overlay hidden" role="dialog" aria-modal="true" style="z-index: 20000; background: rgba(0,0,0,0.85); backdrop-filter: blur(8px);">
|
| 450 |
-
<div class="modal-card" style="max-width: 460px; border: 2px solid #ef4444; box-shadow: 0 0 30px rgba(239, 68, 68, 0.4);">
|
| 451 |
-
<h3 style="color: #ef4444; display: flex; align-items: center; gap: 8px;">
|
| 452 |
-
⚠️ Mandatory Password Update Required
|
| 453 |
-
</h3>
|
| 454 |
-
<p style="font-size: 12px; color: #f8fafc; margin-bottom: 12px; line-height: 1.5;">
|
| 455 |
-
Your password does not meet the updated security policy standards. You must set a compliant password before accessing the dashboard.
|
| 456 |
-
</p>
|
| 457 |
-
<div style="background: rgba(15, 23, 42, 0.8); border: 1px solid rgba(239, 68, 68, 0.4); border-radius: 6px; padding: 10px; margin-bottom: 15px; font-size: 11px; color: #cbd5e1; line-height: 1.5;">
|
| 458 |
-
<b style="color: #ef4444;">Required Password Policy:</b>
|
| 459 |
-
<ul style="margin: 4px 0 0 16px; padding: 0;">
|
| 460 |
-
<li>Minimum 8 characters length</li>
|
| 461 |
-
<li>At least 1 uppercase letter (A-Z)</li>
|
| 462 |
-
<li>At least 1 lowercase letter (a-z)</li>
|
| 463 |
-
<li>At least 1 digit (0-9)</li>
|
| 464 |
-
<li>At least 1 special character (e.g. !@#$%^&*)</li>
|
| 465 |
-
</ul>
|
| 466 |
-
</div>
|
| 467 |
-
<div style="display: flex; flex-direction: column; gap: 10px;">
|
| 468 |
-
<div>
|
| 469 |
-
<label for="force-cp-new" style="display: block; font-size: 11px; font-weight: bold; color: #94a3b8; margin-bottom: 4px;">New Compliant Password (1st Entry):</label>
|
| 470 |
-
<input type="password" id="force-cp-new" placeholder="Enter new compliant password" style="width: 100%; box-sizing: border-box;">
|
| 471 |
-
</div>
|
| 472 |
-
<div>
|
| 473 |
-
<label for="force-cp-confirm" style="display: block; font-size: 11px; font-weight: bold; color: #94a3b8; margin-bottom: 4px;">Confirm New Password (2nd Entry):</label>
|
| 474 |
-
<input type="password" id="force-cp-confirm" placeholder="Re-enter new compliant password" style="width: 100%; box-sizing: border-box;">
|
| 475 |
-
</div>
|
| 476 |
-
</div>
|
| 477 |
-
<p id="force-cp-error" style="color: #ef4444; font-size: 11px; margin-top: 10px; font-weight: bold;" class="hidden"></p>
|
| 478 |
-
<div class="modal-actions" style="margin-top: 20px;">
|
| 479 |
-
<button id="force-cp-submit" class="primary" style="width: 100%; background: #ef4444; border-color: #dc2626;">Save New Password & Continue</button>
|
| 480 |
-
</div>
|
| 481 |
-
</div>
|
| 482 |
-
</div>
|
| 483 |
-
|
| 484 |
-
<!-- Toasts -->
|
| 485 |
-
<div id="toasts"></div>
|
| 486 |
-
|
| 487 |
-
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
| 488 |
-
</body>
|
| 489 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash_admin.py
DELETED
|
@@ -1,479 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
NSE Stock Dashboard (NSEDashboard) Administration Utility
|
| 4 |
-
|
| 5 |
-
Console-based interactive application providing granular menu options for
|
| 6 |
-
user administration, password resets, privilege management, data pipeline utilities,
|
| 7 |
-
and system health checks.
|
| 8 |
-
|
| 9 |
-
Adapted specifically for the nsedash project architecture (including session
|
| 10 |
-
management, directory initialization, and utility scripts execution).
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
import os
|
| 14 |
-
import sys
|
| 15 |
-
import argparse
|
| 16 |
-
import subprocess
|
| 17 |
-
from datetime import datetime
|
| 18 |
-
|
| 19 |
-
# Setup paths so we can import nsedash modules
|
| 20 |
-
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
-
if SCRIPT_DIR not in sys.path:
|
| 22 |
-
sys.path.insert(0, SCRIPT_DIR)
|
| 23 |
-
|
| 24 |
-
from werkzeug.security import generate_password_hash, check_password_hash
|
| 25 |
-
|
| 26 |
-
# Import core nsedash components
|
| 27 |
-
from nsedash.core.user_auth import init as auth_init, UserDatabase, SessionManager
|
| 28 |
-
from nsedash.config import Paths, UTILITY_SCRIPTS
|
| 29 |
-
|
| 30 |
-
# Ingest data processing modules
|
| 31 |
-
try:
|
| 32 |
-
import nsedown
|
| 33 |
-
import nsecorpact
|
| 34 |
-
import nseprocessor
|
| 35 |
-
import nsesymhist
|
| 36 |
-
import nsemaxlive
|
| 37 |
-
except ImportError as _imp_err:
|
| 38 |
-
pass
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def get_base_dir():
|
| 42 |
-
return os.environ.get("NSEDASH_BASE_DIR", os.getcwd())
|
| 43 |
-
|
| 44 |
-
def init_auth():
|
| 45 |
-
base_dir = get_base_dir()
|
| 46 |
-
auth_init(base_dir)
|
| 47 |
-
|
| 48 |
-
def print_splash():
|
| 49 |
-
banner = r"""
|
| 50 |
-
======================================================================
|
| 51 |
-
_ _ ____ _____ ____ _ ____ _ _ ____ ___ _ ____ ____
|
| 52 |
-
| \ | / ___|| ____| _ \ / \ / ___|| | | | _ \ / _ \ / \ | _ \| _ \
|
| 53 |
-
| \| \___ \| _| | | | / _ \\___ \| |_| | |_) | | | / _ \| |_) | | | |
|
| 54 |
-
| |\ |___) | |___| |_| / ___ \___) | _ | _ <| |_| / ___ \ _ <| |_| |
|
| 55 |
-
|_| \_|____/|_____|____/_/ \_\____/|_| |_|_| \_\\___/_/ \_\_| \_\____/
|
| 56 |
-
======================================================================
|
| 57 |
-
"""
|
| 58 |
-
print(banner)
|
| 59 |
-
print(" 🛠️ NSEDASH SYSTEM ADMIN CONSOLE")
|
| 60 |
-
print("======================================================================\n")
|
| 61 |
-
|
| 62 |
-
def menu_reset_admin_password():
|
| 63 |
-
print("\n===================================================================")
|
| 64 |
-
print(" 🔑 RESET SUPER ADMIN ('admin') PASSWORD")
|
| 65 |
-
print("===================================================================")
|
| 66 |
-
|
| 67 |
-
init_auth()
|
| 68 |
-
user = UserDatabase.get_user('admin')
|
| 69 |
-
current_hash = user.get('password_hash') if user else None
|
| 70 |
-
print(f"[*] Step 1: Current Stored Password Hash in Database:\n - {current_hash or '(None / Not Set)'}")
|
| 71 |
-
|
| 72 |
-
print("\n[*] Step 2: Enter New Password")
|
| 73 |
-
while True:
|
| 74 |
-
new_pwd = input("Enter new password for super admin 'admin' (press Enter without input to exit): ").strip()
|
| 75 |
-
if not new_pwd:
|
| 76 |
-
print("[*] No input provided. Exiting password reset loop.")
|
| 77 |
-
return
|
| 78 |
-
if len(new_pwd) < 8:
|
| 79 |
-
print("[!] Error: Password must be at least 8 characters long. Please try again.")
|
| 80 |
-
continue
|
| 81 |
-
break
|
| 82 |
-
|
| 83 |
-
print("\n[*] Step 3: Hashing & storing new password in database...")
|
| 84 |
-
try:
|
| 85 |
-
pwd_hash = generate_password_hash(new_pwd, method='pbkdf2:sha256')
|
| 86 |
-
UserDatabase.update_user('admin', {
|
| 87 |
-
'password_hash': pwd_hash,
|
| 88 |
-
'active': True
|
| 89 |
-
})
|
| 90 |
-
SessionManager.clear_sessions_for_user('admin')
|
| 91 |
-
print("[OK] New password hash stored in database.")
|
| 92 |
-
except Exception as e:
|
| 93 |
-
print(f"[!] Error storing password in database: {e}")
|
| 94 |
-
return
|
| 95 |
-
|
| 96 |
-
print("\n[*] Step 4: Extracting stored password hash from database for verification...")
|
| 97 |
-
user = UserDatabase.get_user('admin')
|
| 98 |
-
reextracted_hash = user.get('password_hash') if user else None
|
| 99 |
-
print(f" - Re-extracted Hash: {reextracted_hash}")
|
| 100 |
-
|
| 101 |
-
print("\n[*] Step 5: Comparing extracted password hash with entered new password...")
|
| 102 |
-
is_match = check_password_hash(reextracted_hash, new_pwd) if reextracted_hash else False
|
| 103 |
-
|
| 104 |
-
print("\n===================================================================")
|
| 105 |
-
print(" ✅ SUPER ADMIN PASSWORD VERIFICATION CONFIRMATION")
|
| 106 |
-
print("===================================================================")
|
| 107 |
-
print(f" - Target User Account : admin")
|
| 108 |
-
print(f" - Extracted DB Hash : {reextracted_hash}")
|
| 109 |
-
print(f" - Password Match Check : {'PASSED (True)' if is_match else 'FAILED (False)'}")
|
| 110 |
-
print(" -----------------------------------------------------------------")
|
| 111 |
-
if is_match:
|
| 112 |
-
print(" ✅ Password verified, matches, and confirmed stored in database!")
|
| 113 |
-
else:
|
| 114 |
-
print(" ❌ ERROR: Extracted password does NOT match entered password!")
|
| 115 |
-
print("===================================================================\n")
|
| 116 |
-
|
| 117 |
-
def menu_reset_any_user_password():
|
| 118 |
-
print("\n--- Reset Any User Password ---")
|
| 119 |
-
init_auth()
|
| 120 |
-
target_user = input("Enter username: ").strip()
|
| 121 |
-
if not target_user:
|
| 122 |
-
print("[!] Operation cancelled. Username required.")
|
| 123 |
-
return
|
| 124 |
-
|
| 125 |
-
user = UserDatabase.get_user(target_user)
|
| 126 |
-
if not user:
|
| 127 |
-
print(f"[!] User '{target_user}' does not exist in the database.")
|
| 128 |
-
return
|
| 129 |
-
|
| 130 |
-
new_pwd = input(f"Enter new password for user '{target_user}': ").strip()
|
| 131 |
-
if len(new_pwd) < 8:
|
| 132 |
-
print("[!] Error: Password must be at least 8 characters long.")
|
| 133 |
-
return
|
| 134 |
-
|
| 135 |
-
pwd_hash = generate_password_hash(new_pwd, method='pbkdf2:sha256')
|
| 136 |
-
try:
|
| 137 |
-
UserDatabase.update_user(target_user, {
|
| 138 |
-
'password_hash': pwd_hash,
|
| 139 |
-
'active': True
|
| 140 |
-
})
|
| 141 |
-
SessionManager.clear_sessions_for_user(target_user)
|
| 142 |
-
print(f"[OK] Password for user '{target_user}' reset successfully. User has been logged out.")
|
| 143 |
-
except Exception as e:
|
| 144 |
-
print(f"[!] Error resetting password: {e}")
|
| 145 |
-
|
| 146 |
-
def menu_reset_totp_for_user():
|
| 147 |
-
print("\n--- Reset TOTP for User ---")
|
| 148 |
-
init_auth()
|
| 149 |
-
target_user = input("Enter username to reset TOTP: ").strip()
|
| 150 |
-
if not target_user:
|
| 151 |
-
print("[!] Operation cancelled. Username required.")
|
| 152 |
-
return
|
| 153 |
-
|
| 154 |
-
user = UserDatabase.get_user(target_user)
|
| 155 |
-
if not user:
|
| 156 |
-
print(f"[!] User '{target_user}' does not exist in the database.")
|
| 157 |
-
return
|
| 158 |
-
|
| 159 |
-
try:
|
| 160 |
-
UserDatabase.update_user(target_user, {
|
| 161 |
-
'totp_secret': None,
|
| 162 |
-
'totp_secret_encrypted': None
|
| 163 |
-
})
|
| 164 |
-
SessionManager.clear_sessions_for_user(target_user)
|
| 165 |
-
print(f"[OK] TOTP for user '{target_user}' reset successfully. They will be prompted to set it up on their next login.")
|
| 166 |
-
except Exception as e:
|
| 167 |
-
print(f"[!] Error resetting TOTP: {e}")
|
| 168 |
-
|
| 169 |
-
def menu_full_account_reset():
|
| 170 |
-
print("\n--- Full Account Reset (Force Fresh Registration) ---")
|
| 171 |
-
init_auth()
|
| 172 |
-
target_user = input("Enter username for full reset: ").strip()
|
| 173 |
-
if not target_user:
|
| 174 |
-
print("[!] Operation cancelled. Username required.")
|
| 175 |
-
return
|
| 176 |
-
|
| 177 |
-
user = UserDatabase.get_user(target_user)
|
| 178 |
-
if not user:
|
| 179 |
-
print(f"[!] User '{target_user}' does not exist in the database.")
|
| 180 |
-
return
|
| 181 |
-
|
| 182 |
-
try:
|
| 183 |
-
UserDatabase.update_user(target_user, {
|
| 184 |
-
'password_hash': None,
|
| 185 |
-
'totp_secret': None,
|
| 186 |
-
'totp_secret_encrypted': None
|
| 187 |
-
})
|
| 188 |
-
SessionManager.clear_sessions_for_user(target_user)
|
| 189 |
-
print(f"[OK] Full account reset for '{target_user}'. They have been logged out and will be forced to register a new password and set up TOTP on their next login.")
|
| 190 |
-
except Exception as e:
|
| 191 |
-
print(f"[!] Error resetting account: {e}")
|
| 192 |
-
|
| 193 |
-
def menu_create_new_user():
|
| 194 |
-
print("\n===================================================================")
|
| 195 |
-
print(" ➕ CREATE NEW USER ACCOUNT")
|
| 196 |
-
print("===================================================================")
|
| 197 |
-
init_auth()
|
| 198 |
-
|
| 199 |
-
userid = input("1. Enter Username (userid): ").strip()
|
| 200 |
-
if not userid:
|
| 201 |
-
print("[!] Error: Username is required.")
|
| 202 |
-
return
|
| 203 |
-
|
| 204 |
-
if UserDatabase.get_user(userid):
|
| 205 |
-
print(f"[!] Error: User '{userid}' already exists.")
|
| 206 |
-
return
|
| 207 |
-
|
| 208 |
-
is_admin_input = input("2. Make user an admin? (y/N): ").strip().lower()
|
| 209 |
-
is_admin = is_admin_input == 'y'
|
| 210 |
-
|
| 211 |
-
print("\n[*] Processing user creation...")
|
| 212 |
-
try:
|
| 213 |
-
UserDatabase.create_user(userid, is_admin)
|
| 214 |
-
print("\n===================================================================")
|
| 215 |
-
print(" ✅ NEW USER ACCOUNT CREATED SUCCESSFULLY")
|
| 216 |
-
print("===================================================================")
|
| 217 |
-
print(f" 1. Username : {userid}")
|
| 218 |
-
print(f" 2. Is Admin : {is_admin}")
|
| 219 |
-
print("===================================================================\n")
|
| 220 |
-
except Exception as e:
|
| 221 |
-
print(f"[!] Error creating user account: {e}")
|
| 222 |
-
|
| 223 |
-
def menu_database_health_and_stats():
|
| 224 |
-
print("\n===================================================================")
|
| 225 |
-
print(" 📊 DATABASE HEALTH CHECK & REAL-TIME SYSTEM STATISTICS")
|
| 226 |
-
print("===================================================================")
|
| 227 |
-
init_auth()
|
| 228 |
-
from nsedash.core import user_auth
|
| 229 |
-
|
| 230 |
-
db_file = user_auth._USERS_DB_FILE
|
| 231 |
-
db_exists = db_file and db_file.exists()
|
| 232 |
-
print(f" - Users DB File ({db_file}) : {'[FOUND]' if db_exists else '[MISSING]'}")
|
| 233 |
-
|
| 234 |
-
if not db_exists:
|
| 235 |
-
print("[!] Database file missing. It will be created on first use.")
|
| 236 |
-
return
|
| 237 |
-
|
| 238 |
-
try:
|
| 239 |
-
users = UserDatabase.get_all_users()
|
| 240 |
-
user_count = len(users)
|
| 241 |
-
admin_count = sum(1 for u in users.values() if u.get('is_admin'))
|
| 242 |
-
active_count = sum(1 for u in users.values() if u.get('active'))
|
| 243 |
-
online_count = sum(1 for u in users.values() if u.get('login_status') == 'online')
|
| 244 |
-
|
| 245 |
-
print("-------------------------------------------------------------------")
|
| 246 |
-
print(f" - Total User Accounts : {user_count}")
|
| 247 |
-
print(f" - Active Accounts : {active_count}")
|
| 248 |
-
print(f" - Admin Accounts : {admin_count}")
|
| 249 |
-
print(f" - Online Users : {online_count}")
|
| 250 |
-
print("-------------------------------------------------------------------")
|
| 251 |
-
|
| 252 |
-
print("\n--- User Details ---")
|
| 253 |
-
print(f"{'User ID':<15} | {'Active':<8} | {'Admin':<8} | {'Status':<10} | {'Last Login'}")
|
| 254 |
-
print("-" * 75)
|
| 255 |
-
for uid, udata in users.items():
|
| 256 |
-
is_act = "Yes" if udata.get("active") else "No"
|
| 257 |
-
is_adm = "Yes" if udata.get("is_admin") else "No"
|
| 258 |
-
status = udata.get("login_status") or "offline"
|
| 259 |
-
last_login = udata.get("last_login") or "Never"
|
| 260 |
-
print(f"{uid:<15} | {is_act:<8} | {is_adm:<8} | {status:<10} | {last_login}")
|
| 261 |
-
print("-" * 75)
|
| 262 |
-
except Exception as e:
|
| 263 |
-
print(f"[!] Health check query error: {e}")
|
| 264 |
-
|
| 265 |
-
def menu_initialize_directories():
|
| 266 |
-
print("\n--- Initialize System Data Directories ---")
|
| 267 |
-
base_dir = get_base_dir()
|
| 268 |
-
paths = Paths(base_dir=base_dir)
|
| 269 |
-
print(f"[*] Base Directory: {paths.base_dir}")
|
| 270 |
-
try:
|
| 271 |
-
paths.ensure_dirs()
|
| 272 |
-
print("[OK] All required directories verified and initialized successfully.")
|
| 273 |
-
except Exception as e:
|
| 274 |
-
print(f"[!] Error initializing directories: {e}")
|
| 275 |
-
|
| 276 |
-
def menu_cleanup_sessions():
|
| 277 |
-
print("\n--- Clear Expired Inactive Sessions ---")
|
| 278 |
-
init_auth()
|
| 279 |
-
try:
|
| 280 |
-
cleaned_count = SessionManager.cleanup_expired()
|
| 281 |
-
print(f"[OK] Cleaned up {cleaned_count} expired session(s).")
|
| 282 |
-
except Exception as e:
|
| 283 |
-
print(f"[!] Error cleaning up sessions: {e}")
|
| 284 |
-
|
| 285 |
-
def run_utility_script(script_name, script_file):
|
| 286 |
-
print(f"\n--- Running Utility: {script_name} ({script_file}) ---")
|
| 287 |
-
script_path = os.path.join(SCRIPT_DIR, script_file)
|
| 288 |
-
if not os.path.exists(script_path):
|
| 289 |
-
print(f"[!] Script {script_file} not found in {SCRIPT_DIR}.")
|
| 290 |
-
return
|
| 291 |
-
|
| 292 |
-
try:
|
| 293 |
-
print(f"[*] Executing {script_file}...\n")
|
| 294 |
-
subprocess.run([sys.executable, script_path], check=True, cwd=SCRIPT_DIR)
|
| 295 |
-
print(f"\n[OK] Script {script_file} completed successfully.")
|
| 296 |
-
except subprocess.CalledProcessError as e:
|
| 297 |
-
print(f"\n[!] Script {script_file} failed with exit code {e.returncode}.")
|
| 298 |
-
except Exception as e:
|
| 299 |
-
print(f"\n[!] Error executing script: {e}")
|
| 300 |
-
|
| 301 |
-
def run_data_pipeline():
|
| 302 |
-
print("\n================================================================================")
|
| 303 |
-
print(" 🚀 RUNNING FULL DATA PIPELINE (1 -> 5 IN SEQUENCE)")
|
| 304 |
-
print("================================================================================")
|
| 305 |
-
pipeline_scripts = [
|
| 306 |
-
("Download Daily Data", "nsedown.py"),
|
| 307 |
-
("Process Corporate Actions", "nsecorpact.py"),
|
| 308 |
-
("Process Daily Data", "nseprocessor.py"),
|
| 309 |
-
("Process Max / Min", "nsesymhist.py"),
|
| 310 |
-
("Download Live Data", "nsemaxlive.py"),
|
| 311 |
-
]
|
| 312 |
-
for idx, (label, script_file) in enumerate(pipeline_scripts, 1):
|
| 313 |
-
print(f"\n[Step {idx}/{len(pipeline_scripts)}] Running {label} ({script_file})...")
|
| 314 |
-
run_utility_script(label, script_file)
|
| 315 |
-
print("\n================================================================================")
|
| 316 |
-
print(" ✅ FULL DATA PIPELINE EXECUTION COMPLETED")
|
| 317 |
-
print("================================================================================")
|
| 318 |
-
|
| 319 |
-
def apply_corporate_action_pipeline():
|
| 320 |
-
print("\n================================================================================")
|
| 321 |
-
print(" 🔄 APPLY CORPORATE ACTIONS PIPELINE")
|
| 322 |
-
print("================================================================================")
|
| 323 |
-
base_dir = get_base_dir()
|
| 324 |
-
processed_dir = os.path.join(base_dir, "processed")
|
| 325 |
-
|
| 326 |
-
files_to_delete = [
|
| 327 |
-
os.path.join(processed_dir, "nse_processed_data.parquet"),
|
| 328 |
-
os.path.join(processed_dir, "nse_processed_data.csv"),
|
| 329 |
-
os.path.join(processed_dir, "nse_summary_data.parquet"),
|
| 330 |
-
os.path.join(processed_dir, "nse_summary_data.csv"),
|
| 331 |
-
]
|
| 332 |
-
|
| 333 |
-
print("[*] Step 1: Deleting existing processed and extract data files...")
|
| 334 |
-
deleted_count = 0
|
| 335 |
-
for file_path in files_to_delete:
|
| 336 |
-
if os.path.exists(file_path):
|
| 337 |
-
try:
|
| 338 |
-
os.remove(file_path)
|
| 339 |
-
print(f" - Deleted: {os.path.basename(file_path)}")
|
| 340 |
-
deleted_count += 1
|
| 341 |
-
except Exception as e:
|
| 342 |
-
print(f" [!] Error deleting {os.path.basename(file_path)}: {e}")
|
| 343 |
-
if deleted_count == 0:
|
| 344 |
-
print(" - No existing processed/extract files found to delete.")
|
| 345 |
-
|
| 346 |
-
pipeline_scripts = [
|
| 347 |
-
("Download Daily Data", "nsedown.py"),
|
| 348 |
-
("Process Corporate Actions", "nsecorpact.py"),
|
| 349 |
-
("Process Daily Data", "nseprocessor.py"),
|
| 350 |
-
]
|
| 351 |
-
|
| 352 |
-
print("\n[*] Step 2: Running pipeline modules sequentially (1 -> 3)...")
|
| 353 |
-
for idx, (label, script_file) in enumerate(pipeline_scripts, 1):
|
| 354 |
-
print(f"\n[Step {idx}/{len(pipeline_scripts)}] Running {label} ({script_file})...")
|
| 355 |
-
run_utility_script(label, script_file)
|
| 356 |
-
|
| 357 |
-
print("\n================================================================================")
|
| 358 |
-
print(" ✅ CORPORATE ACTION PIPELINE COMPLETED")
|
| 359 |
-
print("================================================================================")
|
| 360 |
-
|
| 361 |
-
def menu_utility_scripts():
|
| 362 |
-
while True:
|
| 363 |
-
print("\n================================================================================")
|
| 364 |
-
print(" ⚙️ UTILITY SCRIPTS & DATA PIPELINE ⚙️")
|
| 365 |
-
print("================================================================================")
|
| 366 |
-
|
| 367 |
-
scripts = list(UTILITY_SCRIPTS)
|
| 368 |
-
scripts.append(("Push to HuggingFace / Backup", "push_to_hf.py"))
|
| 369 |
-
|
| 370 |
-
for idx, (label, filename) in enumerate(scripts, 1):
|
| 371 |
-
print(f" [{idx}] {label} ({filename})")
|
| 372 |
-
|
| 373 |
-
run_pipeline_idx = len(scripts) + 1
|
| 374 |
-
corpact_pipeline_idx = len(scripts) + 2
|
| 375 |
-
|
| 376 |
-
print(" ------------------------------------------------------------------------------")
|
| 377 |
-
print(f" [{run_pipeline_idx}] Run Data Pipeline (Execute 1-5 sequentially)")
|
| 378 |
-
print(f" [{corpact_pipeline_idx}] Apply Corporate Action (Delete processed data & execute 1,2,3)")
|
| 379 |
-
print(" ------------------------------------------------------------------------------")
|
| 380 |
-
print(" [0] Return to Main Menu")
|
| 381 |
-
print("================================================================================")
|
| 382 |
-
|
| 383 |
-
choice = input(f"Select an option (0-{corpact_pipeline_idx}): ").strip()
|
| 384 |
-
if choice == '0':
|
| 385 |
-
break
|
| 386 |
-
|
| 387 |
-
try:
|
| 388 |
-
val = int(choice)
|
| 389 |
-
if 1 <= val <= len(scripts):
|
| 390 |
-
label, filename = scripts[val - 1]
|
| 391 |
-
run_utility_script(label, filename)
|
| 392 |
-
elif val == run_pipeline_idx:
|
| 393 |
-
run_data_pipeline()
|
| 394 |
-
elif val == corpact_pipeline_idx:
|
| 395 |
-
apply_corporate_action_pipeline()
|
| 396 |
-
else:
|
| 397 |
-
print("[!] Invalid option.")
|
| 398 |
-
except ValueError:
|
| 399 |
-
print("[!] Please enter a valid number.")
|
| 400 |
-
|
| 401 |
-
input("\nPress Enter to continue...")
|
| 402 |
-
|
| 403 |
-
def print_interactive_menu():
|
| 404 |
-
print("""
|
| 405 |
-
================================================================================
|
| 406 |
-
🎓 NSEDASH - INITIALIZATION & SYSTEM ADMIN CONSOLE 🎓
|
| 407 |
-
================================================================================
|
| 408 |
-
[1] User Management: Reset Super Admin ('admin') Password
|
| 409 |
-
[2] User Management: Reset Any User Account Password
|
| 410 |
-
[3] User Management: Reset User TOTP Authentication
|
| 411 |
-
[4] User Management: Full Account Reset (Force Fresh Registration)
|
| 412 |
-
[5] User Management: Create New User Account
|
| 413 |
-
------------------------------------------------------------------------------
|
| 414 |
-
[6] System Health: Display Database Health & Real-time Statistics
|
| 415 |
-
[7] System Health: Initialize System Data Directories
|
| 416 |
-
[8] System Health: Clear Expired Inactive Sessions
|
| 417 |
-
------------------------------------------------------------------------------
|
| 418 |
-
[9] Utility Scripts: Run Data Pipeline / Sync Tasks
|
| 419 |
-
------------------------------------------------------------------------------
|
| 420 |
-
[0] Exit Administrative Console
|
| 421 |
-
================================================================================
|
| 422 |
-
""")
|
| 423 |
-
|
| 424 |
-
def interactive_console_menu():
|
| 425 |
-
print_splash()
|
| 426 |
-
while True:
|
| 427 |
-
print_interactive_menu()
|
| 428 |
-
try:
|
| 429 |
-
choice = input("Select an option (0-9): ").strip()
|
| 430 |
-
if choice == '1':
|
| 431 |
-
menu_reset_admin_password()
|
| 432 |
-
elif choice == '2':
|
| 433 |
-
menu_reset_any_user_password()
|
| 434 |
-
elif choice == '3':
|
| 435 |
-
menu_reset_totp_for_user()
|
| 436 |
-
elif choice == '4':
|
| 437 |
-
menu_full_account_reset()
|
| 438 |
-
elif choice == '5':
|
| 439 |
-
menu_create_new_user()
|
| 440 |
-
elif choice == '6':
|
| 441 |
-
menu_database_health_and_stats()
|
| 442 |
-
elif choice == '7':
|
| 443 |
-
menu_initialize_directories()
|
| 444 |
-
elif choice == '8':
|
| 445 |
-
menu_cleanup_sessions()
|
| 446 |
-
elif choice == '9':
|
| 447 |
-
menu_utility_scripts()
|
| 448 |
-
elif choice == '0':
|
| 449 |
-
print("[*] Exiting Administrative Console. Goodbye!")
|
| 450 |
-
break
|
| 451 |
-
else:
|
| 452 |
-
print("[!] Invalid option. Please select a number between 0 and 9.")
|
| 453 |
-
except (KeyboardInterrupt, EOFError):
|
| 454 |
-
print("\n[*] Exiting Administrative Console. Goodbye!")
|
| 455 |
-
break
|
| 456 |
-
input("\nPress Enter to return to the main menu...")
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
def main():
|
| 460 |
-
parser = argparse.ArgumentParser(description="NSEDashboard Database Initialization & Management Utility")
|
| 461 |
-
parser.add_argument('--reset-admin-password', type=str, help='Reset super admin password')
|
| 462 |
-
args = parser.parse_args()
|
| 463 |
-
|
| 464 |
-
if args.reset_admin_password:
|
| 465 |
-
init_auth()
|
| 466 |
-
pwd_hash = generate_password_hash(args.reset_admin_password, method='pbkdf2:sha256')
|
| 467 |
-
UserDatabase.update_user('admin', {
|
| 468 |
-
'password_hash': pwd_hash,
|
| 469 |
-
'active': True
|
| 470 |
-
})
|
| 471 |
-
SessionManager.clear_sessions_for_user('admin')
|
| 472 |
-
print("[*] Admin password reset successfully.")
|
| 473 |
-
return
|
| 474 |
-
|
| 475 |
-
# Default to interactive console application mode when executed in terminal
|
| 476 |
-
interactive_console_menu()
|
| 477 |
-
|
| 478 |
-
if __name__ == '__main__':
|
| 479 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash_api.py
DELETED
|
@@ -1,653 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
NSEDash Data Source API Server
|
| 4 |
-
=============================
|
| 5 |
-
|
| 6 |
-
This module provides high-performance, responsive endpoints to retrieve stock symbol data
|
| 7 |
-
from two local parquet files:
|
| 8 |
-
1. nse_processed_data.parquet (EOD stock metrics) on port 8006.
|
| 9 |
-
2. nse_extract_data.parquet (EOD metrics + engineered indicators) on port 8007.
|
| 10 |
-
|
| 11 |
-
Key Architectural Decisions:
|
| 12 |
-
---------------------------
|
| 13 |
-
- Concurrent Dual Apps: Runs two FastAPI applications concurrently using multiprocessing.
|
| 14 |
-
- In-Memory Indexing: Loads and caches Parquet datasets grouped by SYMBOL on startup for O(1) lookups.
|
| 15 |
-
- Hot-Reload Monitoring: Checks Parquet file modified times (mtime) in a background thread
|
| 16 |
-
and reloads data thread-safely using a Reentrant Lock (RLock) without causing service downtime.
|
| 17 |
-
- Gzip HTTP Compression: Employs GZipMiddleware to automatically compress JSON HTTP payloads.
|
| 18 |
-
- Binary Parquet Option: Allows raw binary Parquet outputs via the `format=parquet` query parameter.
|
| 19 |
-
- Transit Encryption: Secures transit data over HTTPS via self-signed SSL/TLS keys and certificates.
|
| 20 |
-
- API Key Access Control: Restricts access to data endpoints using headers or query parameters.
|
| 21 |
-
- Command-line configuration: Configures port bindings, base directories, and hosts via argparse.
|
| 22 |
-
|
| 23 |
-
Endpoints:
|
| 24 |
-
----------
|
| 25 |
-
* GET /api/data/{symbol}
|
| 26 |
-
* GET /api/data
|
| 27 |
-
- Query parameters:
|
| 28 |
-
* start_date (optional, format: YYYY-MM-DD)
|
| 29 |
-
* end_date (optional, format: YYYY-MM-DD)
|
| 30 |
-
* format (optional, options: 'json', 'parquet', default: 'json')
|
| 31 |
-
* api_key (optional, alternative auth query parameter)
|
| 32 |
-
|
| 33 |
-
Usage:
|
| 34 |
-
------
|
| 35 |
-
python nsedash_api.py [--base-dir BASE_DIR] [--host HOST] [--port-processed PORT] [--port-extract PORT]
|
| 36 |
-
"""
|
| 37 |
-
|
| 38 |
-
import argparse
|
| 39 |
-
import io
|
| 40 |
-
import logging
|
| 41 |
-
import multiprocessing
|
| 42 |
-
import os
|
| 43 |
-
import signal
|
| 44 |
-
import sys
|
| 45 |
-
import threading
|
| 46 |
-
import time
|
| 47 |
-
from datetime import datetime
|
| 48 |
-
from pathlib import Path
|
| 49 |
-
from typing import Dict, List, Optional
|
| 50 |
-
|
| 51 |
-
import pandas as pd
|
| 52 |
-
from fastapi import FastAPI, HTTPException, Query, Response, Security, Depends
|
| 53 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 54 |
-
from fastapi.middleware.gzip import GZipMiddleware
|
| 55 |
-
from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
|
| 56 |
-
|
| 57 |
-
# Configure logging
|
| 58 |
-
logging.basicConfig(
|
| 59 |
-
level=logging.INFO,
|
| 60 |
-
format="%(asctime)s - %(levelname)s - %(message)s"
|
| 61 |
-
)
|
| 62 |
-
logger = logging.getLogger("nsedash_api")
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
class ParquetStore:
|
| 66 |
-
"""
|
| 67 |
-
Thread-safe in-memory cache manager for a Parquet dataset.
|
| 68 |
-
|
| 69 |
-
This class loads a Parquet file, indexes it by SYMBOL, and serves structured
|
| 70 |
-
slices by date. It monitors the Parquet file's modification time (mtime)
|
| 71 |
-
on disk and reloads it dynamically when changes are detected.
|
| 72 |
-
"""
|
| 73 |
-
|
| 74 |
-
def __init__(self, filepath: Path):
|
| 75 |
-
"""
|
| 76 |
-
Initialize the ParquetStore and load data for the first time.
|
| 77 |
-
|
| 78 |
-
Args:
|
| 79 |
-
filepath (Path): Path to the target Parquet file.
|
| 80 |
-
"""
|
| 81 |
-
self.filepath = Path(filepath)
|
| 82 |
-
self.lock = threading.RLock()
|
| 83 |
-
self.cache: Dict[str, pd.DataFrame] = {}
|
| 84 |
-
self.last_mtime: float = 0.0
|
| 85 |
-
self.reload()
|
| 86 |
-
|
| 87 |
-
def reload(self) -> None:
|
| 88 |
-
"""
|
| 89 |
-
Thread-safely reloads Parquet data from disk if the file mtime has changed.
|
| 90 |
-
|
| 91 |
-
This method reads the file, parses transaction dates, handles categorical data,
|
| 92 |
-
sorts values, splits the DataFrame by SYMBOL, and performs an atomic swap of the cache.
|
| 93 |
-
"""
|
| 94 |
-
with self.lock:
|
| 95 |
-
if not self.filepath.exists():
|
| 96 |
-
logger.error(f"[STORE] Parquet file not found: {self.filepath}")
|
| 97 |
-
return
|
| 98 |
-
|
| 99 |
-
try:
|
| 100 |
-
current_mtime = os.path.getmtime(self.filepath)
|
| 101 |
-
except OSError as e:
|
| 102 |
-
logger.error(f"[STORE] Failed to read mtime for {self.filepath}: {e}")
|
| 103 |
-
return
|
| 104 |
-
|
| 105 |
-
if current_mtime == self.last_mtime and self.cache:
|
| 106 |
-
# No change on disk, skip reload
|
| 107 |
-
return
|
| 108 |
-
|
| 109 |
-
logger.info(f"[STORE] Loading parquet file: {self.filepath} (change detected)...")
|
| 110 |
-
start_time = time.time()
|
| 111 |
-
try:
|
| 112 |
-
# Load parquet from disk
|
| 113 |
-
df = pd.read_parquet(self.filepath)
|
| 114 |
-
|
| 115 |
-
if "SYMBOL" not in df.columns or "TRAN_DATE" not in df.columns:
|
| 116 |
-
raise ValueError("Parquet file must contain 'SYMBOL' and 'TRAN_DATE' columns.")
|
| 117 |
-
|
| 118 |
-
# Convert transaction date to datetime object
|
| 119 |
-
df["TRAN_DATE"] = pd.to_datetime(df["TRAN_DATE"])
|
| 120 |
-
|
| 121 |
-
# De-categoricalize for compatibility and stability
|
| 122 |
-
for col in df.select_dtypes(include=["category"]).columns:
|
| 123 |
-
df[col] = df[col].astype(str)
|
| 124 |
-
|
| 125 |
-
# Ensure uppercase symbol mapping
|
| 126 |
-
df["SYMBOL"] = df["SYMBOL"].astype(str).str.strip().str.upper()
|
| 127 |
-
|
| 128 |
-
# Chronological sorting for range filtering
|
| 129 |
-
df = df.sort_values(by=["SYMBOL", "TRAN_DATE"])
|
| 130 |
-
|
| 131 |
-
# Group by SYMBOL to build O(1) key cache
|
| 132 |
-
new_cache: Dict[str, pd.DataFrame] = {}
|
| 133 |
-
grouped = df.groupby("SYMBOL", observed=True)
|
| 134 |
-
for sym, group in grouped:
|
| 135 |
-
new_cache[str(sym)] = group.reset_index(drop=True)
|
| 136 |
-
|
| 137 |
-
# Swap references atomically
|
| 138 |
-
self.cache = new_cache
|
| 139 |
-
self.last_mtime = current_mtime
|
| 140 |
-
elapsed = time.time() - start_time
|
| 141 |
-
print(
|
| 142 |
-
f"[STORE] Loaded {self.filepath.name} in {elapsed:.2f}s. "
|
| 143 |
-
f"Total symbols cached: {len(new_cache)}",
|
| 144 |
-
flush=True
|
| 145 |
-
)
|
| 146 |
-
except Exception as e:
|
| 147 |
-
logger.error(f"[STORE] Failed loading Parquet file {self.filepath}: {e}")
|
| 148 |
-
print(f"[STORE ERROR] Failed loading Parquet file {self.filepath}: {e}", flush=True)
|
| 149 |
-
|
| 150 |
-
def get_data(self, symbol: str, start_date: Optional[str] = None, end_date: Optional[str] = None) -> pd.DataFrame:
|
| 151 |
-
"""
|
| 152 |
-
Thread-safely retrieves and filters data for a specific stock symbol and date range.
|
| 153 |
-
|
| 154 |
-
Args:
|
| 155 |
-
symbol (str): Stock ticker symbol.
|
| 156 |
-
start_date (Optional[str]): ISO start date YYYY-MM-DD.
|
| 157 |
-
end_date (Optional[str]): ISO end date YYYY-MM-DD.
|
| 158 |
-
|
| 159 |
-
Returns:
|
| 160 |
-
pd.DataFrame: A filtered slice of data matching the criteria.
|
| 161 |
-
"""
|
| 162 |
-
with self.lock:
|
| 163 |
-
group = self.cache.get(symbol.upper())
|
| 164 |
-
if group is None or group.empty:
|
| 165 |
-
return pd.DataFrame()
|
| 166 |
-
|
| 167 |
-
mask = pd.Series(True, index=group.index)
|
| 168 |
-
if start_date:
|
| 169 |
-
mask &= (group["TRAN_DATE"] >= pd.to_datetime(start_date))
|
| 170 |
-
if end_date:
|
| 171 |
-
mask &= (group["TRAN_DATE"] <= pd.to_datetime(end_date))
|
| 172 |
-
|
| 173 |
-
return group[mask].copy()
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def start_monitoring_thread(store: ParquetStore, interval_seconds: int = 10) -> None:
|
| 177 |
-
"""
|
| 178 |
-
Spawns a daemon thread that periodically triggers ParquetStore check-and-reload.
|
| 179 |
-
|
| 180 |
-
Args:
|
| 181 |
-
store (ParquetStore): Store instance to monitor.
|
| 182 |
-
interval_seconds (int): Polling frequency.
|
| 183 |
-
"""
|
| 184 |
-
def check_loop():
|
| 185 |
-
while True:
|
| 186 |
-
time.sleep(interval_seconds)
|
| 187 |
-
try:
|
| 188 |
-
store.reload()
|
| 189 |
-
except Exception as e:
|
| 190 |
-
logger.error(f"[MONITOR] Error monitoring {store.filepath}: {e}")
|
| 191 |
-
|
| 192 |
-
monitor_thread = threading.Thread(target=check_loop, daemon=True)
|
| 193 |
-
monitor_thread.start()
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
def make_app(store: ParquetStore, port: int, api_key: str) -> FastAPI:
|
| 197 |
-
"""
|
| 198 |
-
FastAPI Application factory configuring routes, logging, security, and middleware.
|
| 199 |
-
|
| 200 |
-
Args:
|
| 201 |
-
store (ParquetStore): Data cache store to query against.
|
| 202 |
-
port (int): Listening port for logging representation.
|
| 203 |
-
api_key (str): Allowed API key value.
|
| 204 |
-
|
| 205 |
-
Returns:
|
| 206 |
-
FastAPI: Ready-to-run web application.
|
| 207 |
-
"""
|
| 208 |
-
app = FastAPI(
|
| 209 |
-
title=f"NSEDash Data Source API - Port {port}",
|
| 210 |
-
description="Failsafe, responsive, and secure symbol data source over HTTPS."
|
| 211 |
-
)
|
| 212 |
-
|
| 213 |
-
# API key extractors
|
| 214 |
-
API_KEY_NAME = "X-API-Key"
|
| 215 |
-
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
| 216 |
-
api_key_query = APIKeyQuery(name="api_key", auto_error=False)
|
| 217 |
-
|
| 218 |
-
def verify_api_key(
|
| 219 |
-
header_key: Optional[str] = Security(api_key_header),
|
| 220 |
-
query_key: Optional[str] = Security(api_key_query)
|
| 221 |
-
) -> None:
|
| 222 |
-
"""Validates API Key using headers or query parameters."""
|
| 223 |
-
if header_key == api_key or query_key == api_key:
|
| 224 |
-
return
|
| 225 |
-
raise HTTPException(
|
| 226 |
-
status_code=403,
|
| 227 |
-
detail="Could not validate credentials. Invalid API Key. Provide X-API-Key header or api_key query parameter."
|
| 228 |
-
)
|
| 229 |
-
|
| 230 |
-
# Enable CORS to allow direct requests from browsers
|
| 231 |
-
app.add_middleware(
|
| 232 |
-
CORSMiddleware,
|
| 233 |
-
allow_origins=["*"],
|
| 234 |
-
allow_credentials=True,
|
| 235 |
-
allow_methods=["*"],
|
| 236 |
-
allow_headers=["*"],
|
| 237 |
-
)
|
| 238 |
-
|
| 239 |
-
# Enable transparent Gzip encoding response compression
|
| 240 |
-
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 241 |
-
|
| 242 |
-
@app.middleware("http")
|
| 243 |
-
async def log_requests(request, call_next):
|
| 244 |
-
"""Logs each API data request with port, duration, size, authentication, and status."""
|
| 245 |
-
start_time = time.time()
|
| 246 |
-
response = await call_next(request)
|
| 247 |
-
process_time = (time.time() - start_time) * 1000
|
| 248 |
-
|
| 249 |
-
path = request.url.path
|
| 250 |
-
if path.startswith("/api/data"):
|
| 251 |
-
client_ip = request.client.host if request.client else "unknown"
|
| 252 |
-
status_code = response.status_code
|
| 253 |
-
content_length = response.headers.get("content-length", "unknown")
|
| 254 |
-
if content_length != "unknown":
|
| 255 |
-
size_str = f"{float(content_length)/1024:.2f}KB"
|
| 256 |
-
else:
|
| 257 |
-
size_str = "unknown"
|
| 258 |
-
|
| 259 |
-
auth_status = "AUTHORIZED" if status_code != 403 else "UNAUTHORIZED"
|
| 260 |
-
|
| 261 |
-
# Print request trace to terminal
|
| 262 |
-
print(
|
| 263 |
-
f"[API REQUEST] {request.method} {request.url.path} from {client_ip} | "
|
| 264 |
-
f"Status: {status_code} ({auth_status}) | Size: {size_str} | "
|
| 265 |
-
f"Time: {process_time:.2f}ms | Port: {port}",
|
| 266 |
-
flush=True
|
| 267 |
-
)
|
| 268 |
-
|
| 269 |
-
return response
|
| 270 |
-
|
| 271 |
-
@app.get("/api/data/{symbol}", dependencies=[Depends(verify_api_key)])
|
| 272 |
-
def get_data_by_symbol(
|
| 273 |
-
symbol: str,
|
| 274 |
-
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
| 275 |
-
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
|
| 276 |
-
format: str = Query("json", description="Response format: 'json' or 'parquet'")
|
| 277 |
-
):
|
| 278 |
-
"""
|
| 279 |
-
Retrieves filtered EOD data for a stock symbol (requires API Key auth).
|
| 280 |
-
|
| 281 |
-
Supports output format of either Gzip-compressed JSON or raw binary Parquet.
|
| 282 |
-
"""
|
| 283 |
-
# Validate date formats
|
| 284 |
-
if start_date:
|
| 285 |
-
try:
|
| 286 |
-
pd.to_datetime(start_date)
|
| 287 |
-
except Exception:
|
| 288 |
-
raise HTTPException(status_code=400, detail="Invalid start_date format. Use YYYY-MM-DD.")
|
| 289 |
-
if end_date:
|
| 290 |
-
try:
|
| 291 |
-
pd.to_datetime(end_date)
|
| 292 |
-
except Exception:
|
| 293 |
-
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD.")
|
| 294 |
-
|
| 295 |
-
symbol = symbol.strip().upper()
|
| 296 |
-
df_slice = store.get_data(symbol, start_date, end_date)
|
| 297 |
-
|
| 298 |
-
if format.lower() == "parquet":
|
| 299 |
-
# Output raw Parquet bytes in-memory buffer
|
| 300 |
-
buffer = io.BytesIO()
|
| 301 |
-
df_slice.to_parquet(buffer, index=False, compression="snappy")
|
| 302 |
-
buffer.seek(0)
|
| 303 |
-
return Response(
|
| 304 |
-
content=buffer.getvalue(),
|
| 305 |
-
media_type="application/octet-stream",
|
| 306 |
-
headers={
|
| 307 |
-
"Content-Disposition": f"attachment; filename={symbol}_data.parquet"
|
| 308 |
-
}
|
| 309 |
-
)
|
| 310 |
-
else:
|
| 311 |
-
# Default to JSON format
|
| 312 |
-
df_json = df_slice.copy()
|
| 313 |
-
if not df_json.empty:
|
| 314 |
-
df_json["TRAN_DATE"] = df_json["TRAN_DATE"].dt.strftime("%Y-%m-%d")
|
| 315 |
-
records = df_json.to_dict(orient="records")
|
| 316 |
-
return {"symbol": symbol, "count": len(records), "data": records}
|
| 317 |
-
|
| 318 |
-
@app.get("/api/data", dependencies=[Depends(verify_api_key)])
|
| 319 |
-
def get_data_query(
|
| 320 |
-
symbol: str = Query(..., description="NSE Stock Symbol"),
|
| 321 |
-
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
| 322 |
-
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
|
| 323 |
-
format: str = Query("json", description="Response format: 'json' or 'parquet'")
|
| 324 |
-
):
|
| 325 |
-
"""Alternative endpoint allowing parameters to be passed entirely via query parameters."""
|
| 326 |
-
return get_data_by_symbol(symbol=symbol, start_date=start_date, end_date=end_date, format=format)
|
| 327 |
-
|
| 328 |
-
@app.get("/api/indicators", dependencies=[Depends(verify_api_key)])
|
| 329 |
-
def get_indicators():
|
| 330 |
-
"""Returns the list of indicator and feature columns present in the Parquet store."""
|
| 331 |
-
with store.lock:
|
| 332 |
-
if not store.cache:
|
| 333 |
-
return {"indicators": []}
|
| 334 |
-
first_df = next(iter(store.cache.values()))
|
| 335 |
-
columns = list(first_df.columns)
|
| 336 |
-
exclude = {"SYMBOL", "TRAN_DATE"}
|
| 337 |
-
indicators = [col for col in columns if col not in exclude]
|
| 338 |
-
return {"indicators": indicators}
|
| 339 |
-
|
| 340 |
-
@app.get("/status")
|
| 341 |
-
def status():
|
| 342 |
-
"""Returns internal state metrics of the Parquet store and cached items (No auth required)."""
|
| 343 |
-
return {
|
| 344 |
-
"status": "online",
|
| 345 |
-
"file": str(store.filepath),
|
| 346 |
-
"last_mtime": store.last_mtime,
|
| 347 |
-
"symbols_count": len(store.cache)
|
| 348 |
-
}
|
| 349 |
-
|
| 350 |
-
return app
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
def ensure_ssl_credentials(base_dir: Path) -> tuple[Path, Path]:
|
| 354 |
-
"""
|
| 355 |
-
Ensures that self-signed SSL certificate and key files exist on disk.
|
| 356 |
-
If not, dynamically generates them under processed/security/ in a thread-safe way.
|
| 357 |
-
|
| 358 |
-
Returns:
|
| 359 |
-
tuple[Path, Path]: Absolute paths to (cert_path, key_path).
|
| 360 |
-
"""
|
| 361 |
-
security_dir = base_dir / "processed" / "security"
|
| 362 |
-
security_dir.mkdir(parents=True, exist_ok=True)
|
| 363 |
-
|
| 364 |
-
cert_path = security_dir / "api_self_signed.crt"
|
| 365 |
-
key_path = security_dir / "api_self_signed.key"
|
| 366 |
-
|
| 367 |
-
if cert_path.exists() and key_path.exists():
|
| 368 |
-
return cert_path, key_path
|
| 369 |
-
|
| 370 |
-
logger.info("[SSL] Generating new self-signed SSL credentials for localhost...")
|
| 371 |
-
try:
|
| 372 |
-
import datetime
|
| 373 |
-
import ipaddress
|
| 374 |
-
from cryptography import x509
|
| 375 |
-
from cryptography.x509.oid import NameOID
|
| 376 |
-
from cryptography.hazmat.primitives import hashes
|
| 377 |
-
from cryptography.hazmat.primitives.asymmetric import rsa
|
| 378 |
-
from cryptography.hazmat.primitives import serialization
|
| 379 |
-
|
| 380 |
-
# 1. Generate private key
|
| 381 |
-
private_key = rsa.generate_private_key(
|
| 382 |
-
public_exponent=65537,
|
| 383 |
-
key_size=2048
|
| 384 |
-
)
|
| 385 |
-
|
| 386 |
-
# Write private key
|
| 387 |
-
key_path.write_bytes(
|
| 388 |
-
private_key.private_bytes(
|
| 389 |
-
encoding=serialization.Encoding.PEM,
|
| 390 |
-
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
| 391 |
-
encryption_algorithm=serialization.NoEncryption()
|
| 392 |
-
)
|
| 393 |
-
)
|
| 394 |
-
|
| 395 |
-
# 2. Build self-signed certificate
|
| 396 |
-
subject = issuer = x509.Name([
|
| 397 |
-
x509.NameAttribute(NameOID.COUNTRY_NAME, "IN"),
|
| 398 |
-
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Maharashtra"),
|
| 399 |
-
x509.NameAttribute(NameOID.LOCALITY_NAME, "Mumbai"),
|
| 400 |
-
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "NSEDash"),
|
| 401 |
-
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
|
| 402 |
-
])
|
| 403 |
-
|
| 404 |
-
cert = (
|
| 405 |
-
x509.CertificateBuilder()
|
| 406 |
-
.subject_name(subject)
|
| 407 |
-
.issuer_name(issuer)
|
| 408 |
-
.public_key(private_key.public_key())
|
| 409 |
-
.serial_number(x509.random_serial_number())
|
| 410 |
-
.not_valid_before(datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1))
|
| 411 |
-
.not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365))
|
| 412 |
-
.add_extension(
|
| 413 |
-
x509.SubjectAlternativeName([
|
| 414 |
-
x509.DNSName("localhost"),
|
| 415 |
-
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1"))
|
| 416 |
-
]),
|
| 417 |
-
critical=False,
|
| 418 |
-
)
|
| 419 |
-
.sign(private_key, hashes.SHA256())
|
| 420 |
-
)
|
| 421 |
-
|
| 422 |
-
# Write certificate
|
| 423 |
-
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
| 424 |
-
logger.info(f"[SSL] Successfully generated certificate at {cert_path}")
|
| 425 |
-
except Exception as e:
|
| 426 |
-
logger.error(f"[SSL] Failed generating self-signed SSL credentials: {e}")
|
| 427 |
-
raise RuntimeError(f"Failed to establish SSL/TLS encryption for transit: {e}")
|
| 428 |
-
|
| 429 |
-
return cert_path, key_path
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
def ensure_api_key(base_dir: Path) -> str:
|
| 433 |
-
"""
|
| 434 |
-
Ensures that a persistent API key exists for data source requests.
|
| 435 |
-
Loads it from processed/security/api_key.txt if present, otherwise generates a new one.
|
| 436 |
-
|
| 437 |
-
Returns:
|
| 438 |
-
str: Secure API key token.
|
| 439 |
-
"""
|
| 440 |
-
security_dir = base_dir / "processed" / "security"
|
| 441 |
-
security_dir.mkdir(parents=True, exist_ok=True)
|
| 442 |
-
key_file = security_dir / "api_key.txt"
|
| 443 |
-
|
| 444 |
-
if key_file.exists():
|
| 445 |
-
try:
|
| 446 |
-
api_key = key_file.read_text(encoding="utf-8").strip()
|
| 447 |
-
if api_key:
|
| 448 |
-
return api_key
|
| 449 |
-
except Exception as e:
|
| 450 |
-
logger.error(f"[AUTH] Error reading API key file: {e}")
|
| 451 |
-
|
| 452 |
-
# Generate a new secure token
|
| 453 |
-
import secrets
|
| 454 |
-
api_key = secrets.token_urlsafe(32)
|
| 455 |
-
try:
|
| 456 |
-
key_file.write_text(api_key, encoding="utf-8")
|
| 457 |
-
logger.info(f"[AUTH] Generated new API key at {key_file}")
|
| 458 |
-
except Exception as e:
|
| 459 |
-
logger.error(f"[AUTH] Failed to save API key to file: {e}")
|
| 460 |
-
|
| 461 |
-
return api_key
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
def start_server_process(
|
| 465 |
-
base_dir: str, file_path: str, host: str, port: int, api_key: str, cert_path: str, key_path: str
|
| 466 |
-
) -> None:
|
| 467 |
-
"""
|
| 468 |
-
Subprocess entry point to spin up a single Port FastAPI Uvicorn listener.
|
| 469 |
-
|
| 470 |
-
Args:
|
| 471 |
-
base_dir (str): Working directory path.
|
| 472 |
-
file_path (str): Relative path to source Parquet file under base_dir.
|
| 473 |
-
host (str): Bound interface address.
|
| 474 |
-
port (int): Assigned port.
|
| 475 |
-
api_key (str): Allowed API Key validation token.
|
| 476 |
-
cert_path (str): Path to self-signed SSL cert.
|
| 477 |
-
key_path (str): Path to self-signed SSL private key.
|
| 478 |
-
"""
|
| 479 |
-
print(f"[*] Initializing store on port {port} for: {file_path}", flush=True)
|
| 480 |
-
try:
|
| 481 |
-
# Load Parquet Store
|
| 482 |
-
store = ParquetStore(Path(base_dir) / file_path)
|
| 483 |
-
|
| 484 |
-
# Start file modification tracking
|
| 485 |
-
start_monitoring_thread(store, interval_seconds=10)
|
| 486 |
-
|
| 487 |
-
# Build FastAPI App
|
| 488 |
-
app = make_app(store, port, api_key)
|
| 489 |
-
|
| 490 |
-
import uvicorn
|
| 491 |
-
# Run Uvicorn listening on port over HTTPS
|
| 492 |
-
uvicorn.run(
|
| 493 |
-
app,
|
| 494 |
-
host=host,
|
| 495 |
-
port=port,
|
| 496 |
-
ssl_keyfile=key_path,
|
| 497 |
-
ssl_certfile=cert_path,
|
| 498 |
-
log_level="warning"
|
| 499 |
-
)
|
| 500 |
-
except KeyboardInterrupt:
|
| 501 |
-
pass
|
| 502 |
-
except Exception as e:
|
| 503 |
-
logger.error(f"[SERVER PROCESS ERROR] Failed to start on port {port}: {e}")
|
| 504 |
-
print(f"[!] Server Process Error on port {port}: {e}", flush=True)
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
def print_splash(host: str, port_processed: int, port_extract: int, base_dir: str, api_key: str) -> None:
|
| 508 |
-
"""
|
| 509 |
-
Prints a beautiful ANSI-colored ASCII splash screen banner on console.
|
| 510 |
-
|
| 511 |
-
Args:
|
| 512 |
-
host (str): Base interface IP.
|
| 513 |
-
port_processed (int): Port of the processed dataset endpoint.
|
| 514 |
-
port_extract (int): Port of the feature extract dataset endpoint.
|
| 515 |
-
base_dir (str): Working base path.
|
| 516 |
-
api_key (str): Active API token key.
|
| 517 |
-
"""
|
| 518 |
-
is_tty = sys.stdout.isatty()
|
| 519 |
-
GREEN = "\033[92m" if is_tty else ""
|
| 520 |
-
CYAN = "\033[96m" if is_tty else ""
|
| 521 |
-
RESET = "\033[0m" if is_tty else ""
|
| 522 |
-
BOLD = "\033[1m" if is_tty else ""
|
| 523 |
-
YELLOW = "\033[93m" if is_tty else ""
|
| 524 |
-
|
| 525 |
-
banner = rf"""{GREEN}{BOLD}
|
| 526 |
-
========================================================================
|
| 527 |
-
_ _ ____ _____ ____ _ ____ _ _ _ ____ ___
|
| 528 |
-
| \ | / ___|| ____| _ \ / \ / ___|| |_| | / \ | _ \_ _|
|
| 529 |
-
| \| \___ \| _| | | | / _ \\___ \| _ | / _ \ | |_) | |
|
| 530 |
-
| |\ |___) | |___| |_| / ___ \___) | | | | / ___ \| __/| |
|
| 531 |
-
|_| \_|____/|_____|____/_/ \_\____/|_| |_|/_/ \_\_| |___|
|
| 532 |
-
========================================================================{RESET}
|
| 533 |
-
* {CYAN}NSE Stock Data Source API Service (FastAPI v0.136 over HTTPS){RESET}
|
| 534 |
-
* Base Directory: {CYAN}{base_dir}{RESET}
|
| 535 |
-
* Processed Data: {CYAN}https://{host}:{port_processed}/api/data/{{symbol}}{RESET}
|
| 536 |
-
* Extract Data: {CYAN}https://{host}:{port_extract}/api/data/{{symbol}}{RESET}
|
| 537 |
-
* API Key (Auth): {YELLOW}{api_key}{RESET}
|
| 538 |
-
* Shutdown: {GREEN}Press Ctrl+C to stop services.{RESET}
|
| 539 |
-
========================================================================
|
| 540 |
-
"""
|
| 541 |
-
print(banner, flush=True)
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
# Multiprocess worker references
|
| 545 |
-
processes: List[multiprocessing.Process] = []
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
def signal_handler(sig, frame) -> None:
|
| 549 |
-
"""SIGINT callback ensuring concurrent children processes are cleaned up."""
|
| 550 |
-
print("\n[Shutdown] Terminating running API server instances...", flush=True)
|
| 551 |
-
for p in processes:
|
| 552 |
-
if p.is_alive():
|
| 553 |
-
try:
|
| 554 |
-
p.terminate()
|
| 555 |
-
p.join(timeout=1.0)
|
| 556 |
-
if p.is_alive():
|
| 557 |
-
p.kill()
|
| 558 |
-
except Exception:
|
| 559 |
-
pass
|
| 560 |
-
print("[Shutdown] Clean exit completed.")
|
| 561 |
-
sys.exit(0)
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
def main() -> None:
|
| 565 |
-
"""Primary parser and process coordinator orchestration loop."""
|
| 566 |
-
parser = argparse.ArgumentParser(
|
| 567 |
-
description="NSEDash stock symbol data source API server.",
|
| 568 |
-
formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
| 569 |
-
)
|
| 570 |
-
parser.add_argument(
|
| 571 |
-
"--base-dir",
|
| 572 |
-
default=None,
|
| 573 |
-
help="Base directory containing the processed Parquet files."
|
| 574 |
-
)
|
| 575 |
-
parser.add_argument(
|
| 576 |
-
"--host",
|
| 577 |
-
default="0.0.0.0",
|
| 578 |
-
help="Host interface to bind the API servers to."
|
| 579 |
-
)
|
| 580 |
-
parser.add_argument(
|
| 581 |
-
"--port-processed",
|
| 582 |
-
type=int,
|
| 583 |
-
default=8006,
|
| 584 |
-
help="Port to run the processed API server on."
|
| 585 |
-
)
|
| 586 |
-
parser.add_argument(
|
| 587 |
-
"--port-extract",
|
| 588 |
-
type=int,
|
| 589 |
-
default=8007,
|
| 590 |
-
help="Port to run the extract API server on."
|
| 591 |
-
)
|
| 592 |
-
args = parser.parse_args()
|
| 593 |
-
|
| 594 |
-
# Resolve working base directory
|
| 595 |
-
base_dir = args.base_dir or os.environ.get("NSEDASH_BASE_DIR") or str(Path.cwd())
|
| 596 |
-
base_path = Path(base_dir)
|
| 597 |
-
|
| 598 |
-
# Relative paths under base directory
|
| 599 |
-
processed_file = "processed/nse_processed_data.parquet"
|
| 600 |
-
extract_file = "processed/nse_extract_data.parquet"
|
| 601 |
-
|
| 602 |
-
# Pre-flight files verification
|
| 603 |
-
if not (base_path / processed_file).exists():
|
| 604 |
-
print(f"[ERROR] Processed parquet file not found: {base_path / processed_file}", flush=True)
|
| 605 |
-
sys.exit(1)
|
| 606 |
-
if not (base_path / extract_file).exists():
|
| 607 |
-
print(f"[ERROR] Extract parquet file not found: {base_path / extract_file}", flush=True)
|
| 608 |
-
sys.exit(1)
|
| 609 |
-
|
| 610 |
-
# Generate or load SSL credentials
|
| 611 |
-
cert_path, key_path = ensure_ssl_credentials(base_path)
|
| 612 |
-
|
| 613 |
-
# Generate or load API Key
|
| 614 |
-
api_key = ensure_api_key(base_path)
|
| 615 |
-
|
| 616 |
-
# Expose key via env variable for tests or local inspection
|
| 617 |
-
os.environ["NSEDASH_API_KEY"] = api_key
|
| 618 |
-
|
| 619 |
-
print_splash(args.host, args.port_processed, args.port_extract, base_dir, api_key)
|
| 620 |
-
|
| 621 |
-
# Set signal traps
|
| 622 |
-
signal.signal(signal.SIGINT, signal_handler)
|
| 623 |
-
|
| 624 |
-
# Use 'spawn' for safe cross-platform multiprocessing startup behaviour
|
| 625 |
-
multiprocessing.set_start_method("spawn", force=True)
|
| 626 |
-
|
| 627 |
-
# Processed database process (port 8006)
|
| 628 |
-
p1 = multiprocessing.Process(
|
| 629 |
-
target=start_server_process,
|
| 630 |
-
args=(base_dir, processed_file, args.host, args.port_processed, api_key, str(cert_path), str(key_path)),
|
| 631 |
-
name="api-processed"
|
| 632 |
-
)
|
| 633 |
-
# Extract database process (port 8007)
|
| 634 |
-
p2 = multiprocessing.Process(
|
| 635 |
-
target=start_server_process,
|
| 636 |
-
args=(base_dir, extract_file, args.host, args.port_extract, api_key, str(cert_path), str(key_path)),
|
| 637 |
-
name="api-extract"
|
| 638 |
-
)
|
| 639 |
-
|
| 640 |
-
processes.append(p1)
|
| 641 |
-
processes.append(p2)
|
| 642 |
-
|
| 643 |
-
# Boot processes
|
| 644 |
-
p1.start()
|
| 645 |
-
p2.start()
|
| 646 |
-
|
| 647 |
-
# Keep primary coordinator thread running
|
| 648 |
-
while True:
|
| 649 |
-
time.sleep(1)
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
if __name__ == "__main__":
|
| 653 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash_app.py
DELETED
|
@@ -1,212 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
NSE Stock Dashboard (NSEDashboard) Application Entry Point.
|
| 4 |
-
|
| 5 |
-
This module serves as the primary executable entry point for the NSE Stock
|
| 6 |
-
Dashboard Flask application. It parses command-line arguments to configure
|
| 7 |
-
the server settings (base directory, host interface, port, and debug mode),
|
| 8 |
-
initializes the Flask application with all required services, and starts
|
| 9 |
-
the Flask WSGI server with multithreading enabled.
|
| 10 |
-
|
| 11 |
-
Features:
|
| 12 |
-
- Configurable base data directory via command line or environment variables.
|
| 13 |
-
- Integrated background task processing framework.
|
| 14 |
-
- Multi-threaded Flask server configuration to maintain frontend responsiveness.
|
| 15 |
-
|
| 16 |
-
Usage:
|
| 17 |
-
python nsedash_app.py [--base-dir BASE_DIR] [--host HOST] [--port PORT] [--debug]
|
| 18 |
-
|
| 19 |
-
Options:
|
| 20 |
-
--base-dir DIR Specify the directory containing the processed stock data files.
|
| 21 |
-
If not provided, the current working directory or the value of the
|
| 22 |
-
$NSEDASH_BASE_DIR environment variable is used.
|
| 23 |
-
--host HOST Host interface to bind the web server to (default: 127.0.0.1).
|
| 24 |
-
--port PORT Port to listen on (default: 5000).
|
| 25 |
-
--debug Run the Flask application in debug mode.
|
| 26 |
-
"""
|
| 27 |
-
|
| 28 |
-
import argparse
|
| 29 |
-
import os
|
| 30 |
-
import signal
|
| 31 |
-
import sys
|
| 32 |
-
import time
|
| 33 |
-
|
| 34 |
-
# ---------------------------------------------------------------------------
|
| 35 |
-
# CLI SWITCHES (mlbench pattern — parsed before argparse for env var side-effects)
|
| 36 |
-
# ---------------------------------------------------------------------------
|
| 37 |
-
|
| 38 |
-
#: -hf Force HuggingFace mode: bind 0.0.0.0:7860, activate HF dataset sync
|
| 39 |
-
ENABLE_HF = '-hf' in sys.argv or '--hf' in sys.argv
|
| 40 |
-
|
| 41 |
-
#: -noadmin Disable admin-only tools (script runner, data utilities)
|
| 42 |
-
ENABLE_NOADMIN = '-noadmin' in sys.argv or '--noadmin' in sys.argv
|
| 43 |
-
|
| 44 |
-
if ENABLE_HF:
|
| 45 |
-
os.environ["HF_MODE"] = "1"
|
| 46 |
-
print("[*] HF mode (-hf): Enabled — binding 0.0.0.0:7860, HF dataset sync active.")
|
| 47 |
-
|
| 48 |
-
if ENABLE_NOADMIN:
|
| 49 |
-
os.environ["ADMIN_MODE"] = "0"
|
| 50 |
-
print("[*] No-admin (-noadmin): Enabled — admin tools disabled.")
|
| 51 |
-
|
| 52 |
-
# Disable CUDA inside Hugging Face Spaces mode to save resources
|
| 53 |
-
if "SPACE_ID" in os.environ or ENABLE_HF:
|
| 54 |
-
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
|
| 55 |
-
os.environ["HF_MODE"] = "1" # Also set from SPACE_ID detection
|
| 56 |
-
|
| 57 |
-
from nsedash.web import create_app
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def get_gpu_info() -> str:
|
| 61 |
-
gpu_info = "CPU Only (No GPU detected)"
|
| 62 |
-
try:
|
| 63 |
-
import torch
|
| 64 |
-
if torch.cuda.is_available():
|
| 65 |
-
gpu_info = f"CUDA Active - {torch.cuda.get_device_name(0)}"
|
| 66 |
-
except ImportError:
|
| 67 |
-
pass
|
| 68 |
-
|
| 69 |
-
try:
|
| 70 |
-
if gpu_info == "CPU Only (No GPU detected)":
|
| 71 |
-
import tensorflow as tf
|
| 72 |
-
gpus = tf.config.list_physical_devices('GPU')
|
| 73 |
-
if gpus:
|
| 74 |
-
gpu_info = f"TensorFlow Active - {len(gpus)} GPU(s)"
|
| 75 |
-
except ImportError:
|
| 76 |
-
pass
|
| 77 |
-
return gpu_info
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def print_console_splash(host, port):
|
| 81 |
-
display_host = "localhost" if host in ("127.0.0.1", "0.0.0.0") else host
|
| 82 |
-
gpu_info = get_gpu_info()
|
| 83 |
-
is_hf = os.environ.get("HF_MODE") == "1" or "SPACE_ID" in os.environ
|
| 84 |
-
admin_enabled = os.environ.get("ADMIN_MODE", "1") == "1"
|
| 85 |
-
|
| 86 |
-
nvidia_info = "Not Tested"
|
| 87 |
-
try:
|
| 88 |
-
from nsedash.core.nvidia_llm import get_nvidia_api_status
|
| 89 |
-
status = get_nvidia_api_status()
|
| 90 |
-
if status.get("success"):
|
| 91 |
-
nvidia_info = f"Tested & Successful ({status.get('source', 'Keyring/Env')})"
|
| 92 |
-
else:
|
| 93 |
-
nvidia_info = f"Failed/Warning ({status.get('message', 'Key missing')})"
|
| 94 |
-
except Exception as e:
|
| 95 |
-
nvidia_info = f"Error: {e}"
|
| 96 |
-
|
| 97 |
-
banner = r"""
|
| 98 |
-
======================================================================
|
| 99 |
-
_ _ ____ _____ ____ _ ____ _ _ ____ ___ _ ____ ____
|
| 100 |
-
| \ | / ___|| ____| _ \ / \ / ___|| | | | _ \ / _ \ / \ | _ \| _ \
|
| 101 |
-
| \| \___ \| _| | | | / _ \\___ \| |_| | |_) | | | / _ \| |_) | | | |
|
| 102 |
-
| |\ |___) | |___| |_| / ___ \___) | _ | _ <| |_| / ___ \ _ <| |_| |
|
| 103 |
-
|_| \_|____/|_____|____/_/ \_\____/|_| |_|_| \_\\___/_/ \_\_| \_\____/
|
| 104 |
-
======================================================================
|
| 105 |
-
"""
|
| 106 |
-
print(banner)
|
| 107 |
-
print(f" * NSE Stock Dashboard (Flask v5.0)")
|
| 108 |
-
print(f" * Running on: http://{display_host}:{port}/")
|
| 109 |
-
print(f" * GPU in use: {gpu_info}")
|
| 110 |
-
print(f" * NVIDIA API: {nvidia_info}")
|
| 111 |
-
print(f" * HF Mode: {'Enabled (HF dataset sync active)' if is_hf else 'Disabled (local mode)'}")
|
| 112 |
-
print(f" * Admin Mode: {'Enabled' if admin_enabled else 'Disabled (-noadmin)'}")
|
| 113 |
-
print(f" * Auth: userid + password + TOTP (mlbench pattern)")
|
| 114 |
-
print(f" * Press Ctrl+C in console to exit gracefully.")
|
| 115 |
-
print("======================================================================\n")
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
_shutdown_in_progress = False
|
| 119 |
-
|
| 120 |
-
def register_shutdown_handler(app):
|
| 121 |
-
def handle_sigint(signum, frame):
|
| 122 |
-
global _shutdown_in_progress
|
| 123 |
-
if _shutdown_in_progress:
|
| 124 |
-
return
|
| 125 |
-
_shutdown_in_progress = True
|
| 126 |
-
|
| 127 |
-
print("\n[Shutdown] Gracefully shutting down nsedash backend...")
|
| 128 |
-
|
| 129 |
-
# 1. Mark as exiting so UI status calls see it
|
| 130 |
-
try:
|
| 131 |
-
service = app.extensions["nsedash"]["service"]
|
| 132 |
-
service.is_exiting = True
|
| 133 |
-
except Exception:
|
| 134 |
-
pass
|
| 135 |
-
|
| 136 |
-
# 2. Wait a short moment for UI to poll/receive the exiting state
|
| 137 |
-
time.sleep(1.5)
|
| 138 |
-
|
| 139 |
-
# 3. Clean up backend components
|
| 140 |
-
try:
|
| 141 |
-
scheduler = app.extensions["nsedash"]["scheduler"]
|
| 142 |
-
scheduler.stop()
|
| 143 |
-
except Exception:
|
| 144 |
-
pass
|
| 145 |
-
|
| 146 |
-
try:
|
| 147 |
-
scripts = app.extensions["nsedash"]["scripts"]
|
| 148 |
-
for run in list(scripts._runs.values()):
|
| 149 |
-
if run.process and run.process.poll() is None:
|
| 150 |
-
run.process.terminate()
|
| 151 |
-
try:
|
| 152 |
-
run.process.wait(timeout=1.0)
|
| 153 |
-
except Exception:
|
| 154 |
-
run.process.kill()
|
| 155 |
-
except Exception:
|
| 156 |
-
pass
|
| 157 |
-
|
| 158 |
-
try:
|
| 159 |
-
runtime = app.extensions["nsedash"]["runtime"]
|
| 160 |
-
runtime.shutdown()
|
| 161 |
-
except Exception:
|
| 162 |
-
pass
|
| 163 |
-
|
| 164 |
-
print("[Shutdown] Backend shutdown complete. Exiting process.")
|
| 165 |
-
sys.exit(0)
|
| 166 |
-
|
| 167 |
-
signal.signal(signal.SIGINT, handle_sigint)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def main():
|
| 171 |
-
parser = argparse.ArgumentParser(
|
| 172 |
-
description="NSE Stock Dashboard (Flask)",
|
| 173 |
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 174 |
-
epilog="""
|
| 175 |
-
CLI switches (mlbench pattern, checked before argparse):
|
| 176 |
-
-hf Force HuggingFace mode: 0.0.0.0:7860, HF dataset sync
|
| 177 |
-
-noadmin Disable admin tools (script runner, data utilities)
|
| 178 |
-
""",
|
| 179 |
-
)
|
| 180 |
-
parser.add_argument("--base-dir", default=None,
|
| 181 |
-
help="Base data directory (default: CWD or $NSEDASH_BASE_DIR)")
|
| 182 |
-
|
| 183 |
-
is_hf = ENABLE_HF or ("SPACE_ID" in os.environ)
|
| 184 |
-
default_host = "0.0.0.0" if is_hf else "127.0.0.1"
|
| 185 |
-
default_port = 7860 if is_hf else 5000
|
| 186 |
-
|
| 187 |
-
parser.add_argument("--host", default=default_host)
|
| 188 |
-
parser.add_argument("--port", type=int, default=default_port)
|
| 189 |
-
parser.add_argument("--debug", action="store_true")
|
| 190 |
-
parser.add_argument("--hf", action="store_true", help="Force HF mode (same as -hf switch)")
|
| 191 |
-
parser.add_argument("--noadmin", action="store_true", help="Disable admin tools (same as -noadmin switch)")
|
| 192 |
-
args = parser.parse_args()
|
| 193 |
-
|
| 194 |
-
# argparse flags also set env vars (in case user passed --hf instead of -hf)
|
| 195 |
-
if args.hf:
|
| 196 |
-
os.environ["HF_MODE"] = "1"
|
| 197 |
-
if args.noadmin:
|
| 198 |
-
os.environ["ADMIN_MODE"] = "0"
|
| 199 |
-
|
| 200 |
-
app = create_app(args.base_dir)
|
| 201 |
-
|
| 202 |
-
# Register shutdown handler & show splash screen
|
| 203 |
-
register_shutdown_handler(app)
|
| 204 |
-
print_console_splash(args.host, args.port)
|
| 205 |
-
|
| 206 |
-
# threaded=True: request handlers stay responsive while background
|
| 207 |
-
# crews run on the dedicated asyncio thread.
|
| 208 |
-
app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
if __name__ == "__main__":
|
| 212 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsedash_app.zip
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2f1000977e348beba9d01290d3b32681fd777d81e2f33fa39d0a76b58ca3830b
|
| 3 |
+
size 971914
|
nsedash_start.py
CHANGED
|
@@ -44,7 +44,7 @@ def _silent_loader():
|
|
| 44 |
if not app_script.exists():
|
| 45 |
app_script = Path("nsedash_app.py")
|
| 46 |
|
| 47 |
-
args = [sys.executable, str(app_script), "-hf"]
|
| 48 |
try:
|
| 49 |
os.execv(sys.executable, args)
|
| 50 |
except Exception:
|
|
|
|
| 44 |
if not app_script.exists():
|
| 45 |
app_script = Path("nsedash_app.py")
|
| 46 |
|
| 47 |
+
args = [sys.executable, str(app_script), "--hf"]
|
| 48 |
try:
|
| 49 |
os.execv(sys.executable, args)
|
| 50 |
except Exception:
|
nsedown.py
DELETED
|
@@ -1,479 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
NSE Bhavcopy Bulk Downloader (Standalone CLI Utility)
|
| 4 |
-
|
| 5 |
-
This script downloads NSE bhavcopy ZIP files from NSE archives.
|
| 6 |
-
It handles downloading files from Jan 1, 2020 to current date,
|
| 7 |
-
organizing them in year-wise folders, and maintaining download logs.
|
| 8 |
-
|
| 9 |
-
Features:
|
| 10 |
-
- CLI Argument support (--days, --output, --verify)
|
| 11 |
-
- Interactive fallback if run without arguments
|
| 12 |
-
- Year-wise file organization
|
| 13 |
-
- Download status tracking and log files
|
| 14 |
-
- Existing file verification (avoids duplicate downloads)
|
| 15 |
-
- Support for Indian market holidays and Diwali Muhurat Trading sessions (up to 2026)
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
import argparse
|
| 19 |
-
import logging
|
| 20 |
-
import os
|
| 21 |
-
import sys
|
| 22 |
-
import time
|
| 23 |
-
import zipfile
|
| 24 |
-
from datetime import datetime, timedelta
|
| 25 |
-
from pathlib import Path
|
| 26 |
-
from typing import List, Set
|
| 27 |
-
|
| 28 |
-
import requests
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class NSEDownloader:
|
| 32 |
-
"""
|
| 33 |
-
NSE Bhavcopy file downloader class.
|
| 34 |
-
Handles downloading of bhavcopy files from NSE archives website.
|
| 35 |
-
"""
|
| 36 |
-
|
| 37 |
-
def __init__(self, days_back: int = 0, downloads_dir: str = "downloads"):
|
| 38 |
-
"""
|
| 39 |
-
Initialize downloader.
|
| 40 |
-
Args:
|
| 41 |
-
days_back: Number of days to look back (0 for full download from 2020-01-01)
|
| 42 |
-
downloads_dir: Directory where files should be downloaded
|
| 43 |
-
"""
|
| 44 |
-
self.days_back = days_back
|
| 45 |
-
self.base_url = "https://nsearchives.nseindia.com/archives/equities/bhavcopy/pr"
|
| 46 |
-
self.base_dir = Path(downloads_dir)
|
| 47 |
-
self.downloads_dir = self.base_dir
|
| 48 |
-
|
| 49 |
-
self.headers = {
|
| 50 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
self.downloaded_files = []
|
| 54 |
-
self.skipped_files = []
|
| 55 |
-
self.failed_files = []
|
| 56 |
-
|
| 57 |
-
self._setup_trading_holidays()
|
| 58 |
-
self.setup_logging()
|
| 59 |
-
self.setup_directories()
|
| 60 |
-
|
| 61 |
-
# Muhurat Trading Days (Diwali Laxmi Pujan)
|
| 62 |
-
# Note: These are Sunday/Saturday holiday dates when special trading sessions occur.
|
| 63 |
-
self.muhurat_dates = {
|
| 64 |
-
datetime(2020, 11, 14), # Diwali 2020
|
| 65 |
-
datetime(2021, 11, 4), # Diwali 2021
|
| 66 |
-
datetime(2022, 10, 24), # Diwali 2022
|
| 67 |
-
datetime(2023, 11, 12), # Diwali 2023
|
| 68 |
-
datetime(2024, 10, 31), # Diwali 2024
|
| 69 |
-
datetime(2025, 10, 21), # Diwali 2025
|
| 70 |
-
datetime(2026, 11, 8), # Diwali 2026
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
def _setup_trading_holidays(self):
|
| 74 |
-
"""Setup NSE trading holidays from 2020 to 2026"""
|
| 75 |
-
self.trading_holidays = {
|
| 76 |
-
# 2020 Holidays
|
| 77 |
-
datetime(2020, 1, 26).date(), # Republic Day
|
| 78 |
-
datetime(2020, 2, 21).date(), # Mahashivratri
|
| 79 |
-
datetime(2020, 3, 10).date(), # Holi
|
| 80 |
-
datetime(2020, 4, 2).date(), # Ram Navami
|
| 81 |
-
datetime(2020, 4, 6).date(), # Mahavir Jayanti
|
| 82 |
-
datetime(2020, 4, 10).date(), # Good Friday
|
| 83 |
-
datetime(2020, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 84 |
-
datetime(2020, 5, 1).date(), # Maharashtra Day
|
| 85 |
-
datetime(2020, 5, 25).date(), # Eid-Ul-Fitr
|
| 86 |
-
datetime(2020, 8, 15).date(), # Independence Day
|
| 87 |
-
datetime(2020, 10, 2).date(), # Gandhi Jayanti
|
| 88 |
-
datetime(2020, 11, 16).date(), # Diwali Balipratipada
|
| 89 |
-
datetime(2020, 11, 30).date(), # Gurunanak Jayanti
|
| 90 |
-
|
| 91 |
-
# 2021 Holidays
|
| 92 |
-
datetime(2021, 1, 26).date(), # Republic Day
|
| 93 |
-
datetime(2021, 3, 11).date(), # Mahashivratri
|
| 94 |
-
datetime(2021, 3, 29).date(), # Holi
|
| 95 |
-
datetime(2021, 4, 2).date(), # Good Friday
|
| 96 |
-
datetime(2021, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 97 |
-
datetime(2021, 4, 21).date(), # Ram Navami
|
| 98 |
-
datetime(2021, 5, 1).date(), # Maharashtra Day
|
| 99 |
-
datetime(2021, 5, 13).date(), # Eid-Ul-Fitr
|
| 100 |
-
datetime(2021, 8, 15).date(), # Independence Day
|
| 101 |
-
datetime(2021, 9, 10).date(), # Ganesh Chaturthi
|
| 102 |
-
datetime(2021, 10, 2).date(), # Gandhi Jayanti
|
| 103 |
-
datetime(2021, 11, 4).date(), # Diwali
|
| 104 |
-
datetime(2021, 11, 5).date(), # Diwali Balipratipada
|
| 105 |
-
datetime(2021, 11, 19).date(), # Gurunanak Jayanti
|
| 106 |
-
|
| 107 |
-
# 2022 Holidays
|
| 108 |
-
datetime(2022, 1, 26).date(), # Republic Day
|
| 109 |
-
datetime(2022, 3, 1).date(), # Mahashivratri
|
| 110 |
-
datetime(2022, 3, 18).date(), # Holi
|
| 111 |
-
datetime(2022, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 112 |
-
datetime(2022, 4, 15).date(), # Good Friday
|
| 113 |
-
datetime(2022, 5, 1).date(), # Maharashtra Day
|
| 114 |
-
datetime(2022, 5, 3).date(), # Eid-Ul-Fitr
|
| 115 |
-
datetime(2022, 8, 15).date(), # Independence Day
|
| 116 |
-
datetime(2022, 10, 2).date(), # Gandhi Jayanti
|
| 117 |
-
datetime(2022, 10, 5).date(), # Dussehra
|
| 118 |
-
datetime(2022, 10, 24).date(), # Diwali Laxmi Pujan
|
| 119 |
-
datetime(2022, 10, 26).date(), # Diwali Balipratipada
|
| 120 |
-
datetime(2022, 11, 8).date(), # Gurunanak Jayanti
|
| 121 |
-
|
| 122 |
-
# 2023 Holidays
|
| 123 |
-
datetime(2023, 1, 26).date(), # Republic Day
|
| 124 |
-
datetime(2023, 3, 7).date(), # Holi
|
| 125 |
-
datetime(2023, 3, 30).date(), # Ram Navami
|
| 126 |
-
datetime(2023, 4, 4).date(), # Mahavir Jayanti
|
| 127 |
-
datetime(2023, 4, 7).date(), # Good Friday
|
| 128 |
-
datetime(2023, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 129 |
-
datetime(2023, 4, 22).date(), # Eid-Ul-Fitr
|
| 130 |
-
datetime(2023, 5, 1).date(), # Maharashtra Day
|
| 131 |
-
datetime(2023, 8, 15).date(), # Independence Day
|
| 132 |
-
datetime(2023, 9, 19).date(), # Ganesh Chaturthi
|
| 133 |
-
datetime(2023, 10, 2).date(), # Gandhi Jayanti
|
| 134 |
-
datetime(2023, 10, 24).date(), # Dussehra
|
| 135 |
-
datetime(2023, 11, 14).date(), # Diwali Balipratipada
|
| 136 |
-
datetime(2023, 11, 27).date(), # Gurunanak Jayanti
|
| 137 |
-
|
| 138 |
-
# 2024 Holidays
|
| 139 |
-
datetime(2024, 1, 26).date(), # Republic Day
|
| 140 |
-
datetime(2024, 3, 8).date(), # Maha Shivaratri
|
| 141 |
-
datetime(2024, 3, 25).date(), # Holi
|
| 142 |
-
datetime(2024, 3, 29).date(), # Good Friday
|
| 143 |
-
datetime(2024, 4, 11).date(), # Eid-Ul-Fitr
|
| 144 |
-
datetime(2024, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 145 |
-
datetime(2024, 4, 17).date(), # Ram Navami
|
| 146 |
-
datetime(2024, 5, 1).date(), # Maharashtra Day
|
| 147 |
-
datetime(2024, 8, 15).date(), # Independence Day
|
| 148 |
-
datetime(2024, 10, 2).date(), # Gandhi Jayanti
|
| 149 |
-
datetime(2024, 10, 31).date(), # Diwali
|
| 150 |
-
datetime(2024, 11, 1).date(), # Diwali Balipratipada
|
| 151 |
-
datetime(2024, 11, 15).date(), # Gurunanak Jayanti
|
| 152 |
-
|
| 153 |
-
# 2025 Holidays
|
| 154 |
-
datetime(2025, 1, 26).date(), # Republic Day
|
| 155 |
-
datetime(2025, 2, 26).date(), # Mahashivratri
|
| 156 |
-
datetime(2025, 3, 14).date(), # Holi
|
| 157 |
-
datetime(2025, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 158 |
-
datetime(2025, 4, 18).date(), # Good Friday
|
| 159 |
-
datetime(2025, 5, 1).date(), # Maharashtra Day
|
| 160 |
-
datetime(2025, 8, 15).date(), # Independence Day
|
| 161 |
-
datetime(2025, 10, 2).date(), # Gandhi Jayanti
|
| 162 |
-
datetime(2025, 10, 20).date(), # Dussehra
|
| 163 |
-
datetime(2025, 10, 22).date(), # Diwali Balipratipada
|
| 164 |
-
datetime(2025, 11, 4).date(), # Gurunanak Jayanti
|
| 165 |
-
|
| 166 |
-
# 2026 Holidays
|
| 167 |
-
datetime(2026, 1, 15).date(), # Municipal Corporation Election - Maharashtra
|
| 168 |
-
datetime(2026, 1, 26).date(), # Republic Day
|
| 169 |
-
datetime(2026, 2, 15).date(), # Mahashivratri
|
| 170 |
-
datetime(2026, 3, 3).date(), # Holi
|
| 171 |
-
datetime(2026, 3, 21).date(), # Id-Ul-Fitr
|
| 172 |
-
datetime(2026, 3, 26).date(), # Ram Navami
|
| 173 |
-
datetime(2026, 3, 31).date(), # Mahavir Jayanti
|
| 174 |
-
datetime(2026, 4, 3).date(), # Good Friday
|
| 175 |
-
datetime(2026, 4, 14).date(), # Dr.Ambedkar Jayanti
|
| 176 |
-
datetime(2026, 5, 1).date(), # Maharashtra Day
|
| 177 |
-
datetime(2026, 5, 28).date(), # Bakri Id
|
| 178 |
-
datetime(2026, 6, 26).date(), # Muharram
|
| 179 |
-
datetime(2026, 8, 15).date(), # Independence Day
|
| 180 |
-
datetime(2026, 9, 14).date(), # Ganesh Chaturthi
|
| 181 |
-
datetime(2026, 10, 2).date(), # Gandhi Jayanti
|
| 182 |
-
datetime(2026, 10, 20).date(), # Dussehra
|
| 183 |
-
datetime(2026, 11, 8).date(), # Diwali Laxmi Pujan (Muhurat Trading day, but regular holiday)
|
| 184 |
-
datetime(2026, 11, 10).date(), # Diwali Balipratipada
|
| 185 |
-
datetime(2026, 11, 24).date(), # Gurunanak Jayanti
|
| 186 |
-
datetime(2026, 12, 25).date(), # Christmas
|
| 187 |
-
|
| 188 |
-
# Additional Missing Historical Holidays (2020-2024)
|
| 189 |
-
datetime(2020, 12, 25).date(), # Christmas
|
| 190 |
-
datetime(2021, 7, 21).date(), # Bakrid
|
| 191 |
-
datetime(2021, 8, 19).date(), # Muharram
|
| 192 |
-
datetime(2021, 10, 15).date(), # Dussehra
|
| 193 |
-
datetime(2022, 8, 9).date(), # Muharram
|
| 194 |
-
datetime(2022, 8, 31).date(), # Ganesh Chaturthi
|
| 195 |
-
datetime(2023, 6, 29).date(), # Bakrid
|
| 196 |
-
datetime(2023, 12, 25).date(), # Christmas
|
| 197 |
-
datetime(2024, 1, 22).date(), # Ram Mandir Inauguration
|
| 198 |
-
datetime(2024, 5, 20).date(), # Buddha Purnima
|
| 199 |
-
datetime(2024, 6, 17).date(), # Bakrid
|
| 200 |
-
datetime(2024, 7, 17).date(), # Muharram
|
| 201 |
-
datetime(2024, 11, 20).date(), # Id-E-Milad
|
| 202 |
-
datetime(2024, 12, 25).date(), # Christmas
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
def _is_weekend(self, date: datetime) -> bool:
|
| 206 |
-
"""Check if date is a weekend (Saturday=5, Sunday=6)"""
|
| 207 |
-
return date.weekday() >= 5
|
| 208 |
-
|
| 209 |
-
def _is_valid_trading_day(self, date: datetime) -> bool:
|
| 210 |
-
"""Check if date is valid for trading including Muhurat"""
|
| 211 |
-
# Always include Muhurat trading days
|
| 212 |
-
# We check date.replace(...) to normalize the time part to 00:00:00
|
| 213 |
-
normalized_dt = date.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 214 |
-
if normalized_dt in self.muhurat_dates:
|
| 215 |
-
return True
|
| 216 |
-
|
| 217 |
-
# For regular days, check weekend and holidays
|
| 218 |
-
return not (self._is_weekend(date) or date.date() in self.trading_holidays)
|
| 219 |
-
|
| 220 |
-
def setup_logging(self):
|
| 221 |
-
"""Configure logging settings"""
|
| 222 |
-
log_format = '%(asctime)s - %(levelname)s - %(message)s'
|
| 223 |
-
logging.basicConfig(
|
| 224 |
-
level=logging.INFO,
|
| 225 |
-
format=log_format,
|
| 226 |
-
handlers=[
|
| 227 |
-
logging.FileHandler(self.base_dir / 'download_log.txt'),
|
| 228 |
-
logging.StreamHandler(sys.stdout)
|
| 229 |
-
]
|
| 230 |
-
)
|
| 231 |
-
self.logger = logging.getLogger(__name__)
|
| 232 |
-
|
| 233 |
-
def setup_directories(self):
|
| 234 |
-
"""Create necessary directories and files"""
|
| 235 |
-
self.base_dir.mkdir(exist_ok=True, parents=True)
|
| 236 |
-
|
| 237 |
-
self.success_file = self.base_dir / "files_downloaded.txt"
|
| 238 |
-
self.failed_file = self.base_dir / "download_failed.txt"
|
| 239 |
-
|
| 240 |
-
self.success_file.touch(exist_ok=True)
|
| 241 |
-
self.failed_file.touch(exist_ok=True)
|
| 242 |
-
|
| 243 |
-
def _get_date_range(self) -> List[datetime]:
|
| 244 |
-
"""Get list of valid trading dates based on lookback period"""
|
| 245 |
-
# Determine date range
|
| 246 |
-
end_date = datetime.now()
|
| 247 |
-
if self.days_back > 0:
|
| 248 |
-
start_date = end_date - timedelta(days=self.days_back)
|
| 249 |
-
else:
|
| 250 |
-
start_date = datetime(2020, 1, 1)
|
| 251 |
-
|
| 252 |
-
# Get all valid dates including Muhurat
|
| 253 |
-
valid_dates = []
|
| 254 |
-
current = start_date
|
| 255 |
-
while current <= end_date:
|
| 256 |
-
if self._is_valid_trading_day(current):
|
| 257 |
-
valid_dates.append(current)
|
| 258 |
-
current += timedelta(days=1)
|
| 259 |
-
|
| 260 |
-
return valid_dates
|
| 261 |
-
|
| 262 |
-
def get_download_path(self, date: datetime) -> Path:
|
| 263 |
-
"""Get year-wise subfolder path for download"""
|
| 264 |
-
year_dir = self.base_dir / str(date.year)
|
| 265 |
-
year_dir.mkdir(exist_ok=True, parents=True)
|
| 266 |
-
return year_dir
|
| 267 |
-
|
| 268 |
-
def download_file(self, date: datetime) -> bool:
|
| 269 |
-
"""Download and verify single file with Muhurat handling"""
|
| 270 |
-
filename = f"PR{date.strftime('%d%m%y')}.zip"
|
| 271 |
-
year_dir = self.get_download_path(date)
|
| 272 |
-
file_path = year_dir / filename
|
| 273 |
-
|
| 274 |
-
# Track if it's a Muhurat trading day
|
| 275 |
-
normalized_dt = date.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 276 |
-
is_muhurat = normalized_dt in self.muhurat_dates
|
| 277 |
-
|
| 278 |
-
# Check if already downloaded and valid
|
| 279 |
-
if file_path.exists():
|
| 280 |
-
try:
|
| 281 |
-
with zipfile.ZipFile(file_path) as zf:
|
| 282 |
-
if zf.testzip() is None:
|
| 283 |
-
self.skipped_files.append({
|
| 284 |
-
'date': date,
|
| 285 |
-
'file': filename,
|
| 286 |
-
'path': file_path,
|
| 287 |
-
'is_muhurat': is_muhurat
|
| 288 |
-
})
|
| 289 |
-
return True
|
| 290 |
-
except:
|
| 291 |
-
file_path.unlink()
|
| 292 |
-
|
| 293 |
-
# Download with retries
|
| 294 |
-
for attempt in range(3):
|
| 295 |
-
try:
|
| 296 |
-
url = f"{self.base_url}/{filename}"
|
| 297 |
-
response = requests.get(url, headers=self.headers, timeout=30)
|
| 298 |
-
response.raise_for_status()
|
| 299 |
-
|
| 300 |
-
with open(file_path, 'wb') as f:
|
| 301 |
-
f.write(response.content)
|
| 302 |
-
|
| 303 |
-
with zipfile.ZipFile(file_path) as zf:
|
| 304 |
-
if zf.testzip() is None:
|
| 305 |
-
self.downloaded_files.append({
|
| 306 |
-
'date': date,
|
| 307 |
-
'file': filename,
|
| 308 |
-
'path': file_path,
|
| 309 |
-
'size': file_path.stat().st_size,
|
| 310 |
-
'is_muhurat': is_muhurat
|
| 311 |
-
})
|
| 312 |
-
return True
|
| 313 |
-
|
| 314 |
-
file_path.unlink()
|
| 315 |
-
|
| 316 |
-
except Exception as e:
|
| 317 |
-
if attempt == 2:
|
| 318 |
-
self.failed_files.append({
|
| 319 |
-
'date': date,
|
| 320 |
-
'file': filename,
|
| 321 |
-
'error': str(e),
|
| 322 |
-
'is_muhurat': is_muhurat
|
| 323 |
-
})
|
| 324 |
-
return False
|
| 325 |
-
time.sleep(2 ** attempt)
|
| 326 |
-
|
| 327 |
-
def print_summary(self, duration: float):
|
| 328 |
-
"""Print detailed download summary with Muhurat indication"""
|
| 329 |
-
print("\nDownload Summary")
|
| 330 |
-
print("═" * 60)
|
| 331 |
-
|
| 332 |
-
print(f"Time Taken: {duration:.1f} seconds")
|
| 333 |
-
print(f"Download Mode: {'Incremental' if self.days_back > 0 else 'Full'}")
|
| 334 |
-
if self.days_back > 0:
|
| 335 |
-
print(f"Days Processed: {self.days_back}")
|
| 336 |
-
print("─" * 60)
|
| 337 |
-
|
| 338 |
-
# Downloaded files
|
| 339 |
-
if self.downloaded_files:
|
| 340 |
-
print("\n✓ Successfully Downloaded:")
|
| 341 |
-
total_size = sum(f['size'] for f in self.downloaded_files)
|
| 342 |
-
|
| 343 |
-
for file in self.downloaded_files:
|
| 344 |
-
muhurat_tag = "[Muhurat]" if file['is_muhurat'] else ""
|
| 345 |
-
print(f" • {file['file']} {muhurat_tag} ({file['size'] / 1024:.1f} KB)")
|
| 346 |
-
print(f" Total Size: {total_size / 1024 / 1024:.2f} MB")
|
| 347 |
-
|
| 348 |
-
# Skipped files
|
| 349 |
-
if self.skipped_files:
|
| 350 |
-
print("\n→ Already Existed:")
|
| 351 |
-
for file in self.skipped_files:
|
| 352 |
-
muhurat_tag = " [Muhurat]" if file['is_muhurat'] else ""
|
| 353 |
-
print(f" • {file['file']}{muhurat_tag}")
|
| 354 |
-
|
| 355 |
-
# Failed files
|
| 356 |
-
if self.failed_files:
|
| 357 |
-
print("\n✗ Failed Downloads:")
|
| 358 |
-
for file in self.failed_files:
|
| 359 |
-
print(f" • {file['file']} - {file['error']}")
|
| 360 |
-
|
| 361 |
-
print("═" * 60)
|
| 362 |
-
|
| 363 |
-
def run(self):
|
| 364 |
-
"""Execute download process with improved tracking"""
|
| 365 |
-
try:
|
| 366 |
-
dates = self._get_date_range()
|
| 367 |
-
if not dates:
|
| 368 |
-
print("✓ No files to download")
|
| 369 |
-
return
|
| 370 |
-
|
| 371 |
-
start_time = time.time()
|
| 372 |
-
total = len(dates)
|
| 373 |
-
|
| 374 |
-
for i, date in enumerate(dates, 1):
|
| 375 |
-
self.download_file(date)
|
| 376 |
-
|
| 377 |
-
# Update progress
|
| 378 |
-
print(f"\rProgress: {i}/{total} "
|
| 379 |
-
f"[{len(self.downloaded_files)}⬇ "
|
| 380 |
-
f"{len(self.skipped_files)}⤍ "
|
| 381 |
-
f"{len(self.failed_files)}✗]",
|
| 382 |
-
end='', flush=True)
|
| 383 |
-
|
| 384 |
-
time.sleep(0.2)
|
| 385 |
-
|
| 386 |
-
# Print detailed summary
|
| 387 |
-
self.print_summary(time.time() - start_time)
|
| 388 |
-
|
| 389 |
-
except KeyboardInterrupt:
|
| 390 |
-
print("\n\n✗ Download interrupted")
|
| 391 |
-
except Exception as e:
|
| 392 |
-
print(f"\n\n✗ Error: {e}")
|
| 393 |
-
logging.error(f"Download failed: {e}")
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def self_verify():
|
| 397 |
-
"""Verify that holiday rules are working as intended"""
|
| 398 |
-
downloader = NSEDownloader(days_back=1, downloads_dir="downloads")
|
| 399 |
-
|
| 400 |
-
test_dates = [
|
| 401 |
-
(datetime(2026, 1, 15), False, "Maharashtra Municipal Elections (Thursday)"),
|
| 402 |
-
(datetime(2026, 1, 26), False, "Republic Day (Monday)"),
|
| 403 |
-
(datetime(2026, 3, 3), False, "Holi (Tuesday)"),
|
| 404 |
-
(datetime(2026, 11, 8), True, "Diwali Muhurat Trading (Sunday)"),
|
| 405 |
-
(datetime(2026, 11, 10), False, "Diwali-Balipratipada (Tuesday)"),
|
| 406 |
-
(datetime(2026, 11, 11), True, "Regular Wednesday"),
|
| 407 |
-
(datetime(2026, 11, 15), False, "Regular Sunday"),
|
| 408 |
-
]
|
| 409 |
-
|
| 410 |
-
passed = True
|
| 411 |
-
print("Self Verification Results:")
|
| 412 |
-
print("=" * 60)
|
| 413 |
-
for dt, expected, desc in test_dates:
|
| 414 |
-
actual = downloader._is_valid_trading_day(dt)
|
| 415 |
-
status = "PASS" if actual == expected else "FAIL"
|
| 416 |
-
if actual != expected:
|
| 417 |
-
passed = False
|
| 418 |
-
print(f" {dt.strftime('%Y-%m-%d')} ({desc}): Expected={expected}, Actual={actual} -> {status}")
|
| 419 |
-
print("=" * 60)
|
| 420 |
-
|
| 421 |
-
if passed:
|
| 422 |
-
print("All holiday logic checks passed successfully!")
|
| 423 |
-
sys.exit(0)
|
| 424 |
-
else:
|
| 425 |
-
print("Self-verification failed!")
|
| 426 |
-
sys.exit(1)
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
def main():
|
| 430 |
-
parser = argparse.ArgumentParser(description="Standalone NSE Bhavcopy Downloader Utility")
|
| 431 |
-
parser.add_argument(
|
| 432 |
-
"-d", "--days",
|
| 433 |
-
type=int,
|
| 434 |
-
help="Number of lookback days (0 for full download from 2020-01-01)"
|
| 435 |
-
)
|
| 436 |
-
parser.add_argument(
|
| 437 |
-
"-o", "--output",
|
| 438 |
-
type=str,
|
| 439 |
-
default="downloads",
|
| 440 |
-
help="Output directory for downloading bhavcopies (default: 'downloads')"
|
| 441 |
-
)
|
| 442 |
-
parser.add_argument(
|
| 443 |
-
"--verify",
|
| 444 |
-
action="store_true",
|
| 445 |
-
help="Run self-verification tests for 2026 trading holiday logic"
|
| 446 |
-
)
|
| 447 |
-
|
| 448 |
-
args = parser.parse_args()
|
| 449 |
-
|
| 450 |
-
if args.verify:
|
| 451 |
-
self_verify()
|
| 452 |
-
return
|
| 453 |
-
|
| 454 |
-
days_back = args.days
|
| 455 |
-
if days_back is None:
|
| 456 |
-
try:
|
| 457 |
-
print("Running in interactive mode. Use -h or --help for command-line options.")
|
| 458 |
-
days_input = input("Enter number of days to look back (0 for full download): ").strip()
|
| 459 |
-
if not days_input:
|
| 460 |
-
days_back = 0
|
| 461 |
-
else:
|
| 462 |
-
days_back = int(days_input)
|
| 463 |
-
except (KeyboardInterrupt, EOFError):
|
| 464 |
-
print("\nExiting.")
|
| 465 |
-
sys.exit(0)
|
| 466 |
-
except ValueError:
|
| 467 |
-
print("Invalid input. Using full download mode.")
|
| 468 |
-
days_back = 0
|
| 469 |
-
|
| 470 |
-
if days_back < 0:
|
| 471 |
-
print("Invalid days input. Setting to full download.")
|
| 472 |
-
days_back = 0
|
| 473 |
-
|
| 474 |
-
downloader = NSEDownloader(days_back=days_back, downloads_dir=args.output)
|
| 475 |
-
downloader.run()
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
if __name__ == "__main__":
|
| 479 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsemaxlive.py
DELETED
|
@@ -1,553 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
NSE Max Live Data Scraper & Watchlist Generator (nsemaxlive.py)
|
| 4 |
-
|
| 5 |
-
Scrapes live market gainers, losers, and volume spurts directly from NSE India
|
| 6 |
-
using Selenium in headless Chrome, processes the raw CSV downloads, and generates
|
| 7 |
-
top 50 JSON watchlists for NSEDash.
|
| 8 |
-
|
| 9 |
-
Features:
|
| 10 |
-
- Headless Chrome scraping for Advance, Decline, and Volume spurts
|
| 11 |
-
- Robust download wait & error recovery retries
|
| 12 |
-
- Processes scraped CSVs to generate JSON watchlists:
|
| 13 |
-
- nse_dashboard_maxvol50.json (Max Volume Portfolio)
|
| 14 |
-
- nse_dashboard_maxprice50.json (Max Price Gainers Portfolio)
|
| 15 |
-
- nse_dashboard_maxfall50.json (Max Price Fall Portfolio)
|
| 16 |
-
- Configurable source and target paths for script execution
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
import argparse
|
| 20 |
-
import json
|
| 21 |
-
import logging
|
| 22 |
-
import os
|
| 23 |
-
import random
|
| 24 |
-
import sys
|
| 25 |
-
import time
|
| 26 |
-
from datetime import datetime
|
| 27 |
-
from pathlib import Path
|
| 28 |
-
import pandas as pd
|
| 29 |
-
|
| 30 |
-
try:
|
| 31 |
-
from selenium import webdriver
|
| 32 |
-
from selenium.common.exceptions import (
|
| 33 |
-
ElementClickInterceptedException,
|
| 34 |
-
NoSuchElementException,
|
| 35 |
-
TimeoutException,
|
| 36 |
-
WebDriverException,
|
| 37 |
-
)
|
| 38 |
-
from selenium.webdriver.chrome.options import Options
|
| 39 |
-
from selenium.webdriver.chrome.service import Service as ChromeService
|
| 40 |
-
from selenium.webdriver.common.by import By
|
| 41 |
-
from selenium.webdriver.support import expected_conditions as EC
|
| 42 |
-
from selenium.webdriver.support.ui import WebDriverWait
|
| 43 |
-
from webdriver_manager.chrome import ChromeDriverManager
|
| 44 |
-
except ImportError:
|
| 45 |
-
webdriver = None
|
| 46 |
-
class WebDriverException(Exception): pass
|
| 47 |
-
class TimeoutException(Exception): pass
|
| 48 |
-
class NoSuchElementException(Exception): pass
|
| 49 |
-
class ElementClickInterceptedException(Exception): pass
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
# Default path handling: file is placed in /source/python/sammax1/nsedata/ (same folder as nsedash_admin.py)
|
| 53 |
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 54 |
-
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / "processed"
|
| 55 |
-
DEFAULT_DOWNLOAD_DIR = SCRIPT_DIR / "tempfiles"
|
| 56 |
-
|
| 57 |
-
# URLs and JavaScript functions for NSE data
|
| 58 |
-
SCRAPE_ELEMENTS = {
|
| 59 |
-
'Advance': {
|
| 60 |
-
'url': 'https://www.nseindia.com/market-data/advance',
|
| 61 |
-
'js_function': "javascript:downloadCSV('Advance-download')",
|
| 62 |
-
'download_button_xpath': "//*[@id='Advance-download']"
|
| 63 |
-
},
|
| 64 |
-
'Decline': {
|
| 65 |
-
'url': 'https://www.nseindia.com/market-data/decline',
|
| 66 |
-
'js_function': "javascript:downloadCSV('Decline-download')",
|
| 67 |
-
'download_button_xpath': "//*[@id='Decline-download']"
|
| 68 |
-
},
|
| 69 |
-
'Volume': {
|
| 70 |
-
'url': 'https://www.nseindia.com/market-data/volume-gainers-spurts',
|
| 71 |
-
'js_function': "javascript:downloadCSVFile()",
|
| 72 |
-
'download_button_xpath': "//*[@id='dwldcsv']"
|
| 73 |
-
}
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def print_splash_screen():
|
| 78 |
-
"""Print stylized ASCII splash screen banner."""
|
| 79 |
-
splash = r"""
|
| 80 |
-
================================================================================
|
| 81 |
-
NSE MAX LIVE DATA SCRAPER & PROCESSOR
|
| 82 |
-
================================================================================
|
| 83 |
-
Automated Selenium Headless Scraper for Live NSE India Market Data
|
| 84 |
-
Outputs: Max Volume (Top 50), Max Price Gainers (Top 50), Max Fall (Top 50)
|
| 85 |
-
================================================================================
|
| 86 |
-
"""
|
| 87 |
-
print(splash)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
class NseDataScraper:
|
| 91 |
-
"""
|
| 92 |
-
A robust web scraper for downloading CSV data from NSE India website.
|
| 93 |
-
It uses Selenium with a headless Chrome browser, incorporating retry mechanisms
|
| 94 |
-
and improved error handling.
|
| 95 |
-
"""
|
| 96 |
-
def __init__(self, download_path, output_path, min_sleep_time=0, max_sleep_time=3, max_retries=5, headless=True):
|
| 97 |
-
"""
|
| 98 |
-
Initializes the NseDataScraper with paths and sets up the headless Chrome WebDriver.
|
| 99 |
-
|
| 100 |
-
Args:
|
| 101 |
-
download_path (str or Path): The directory where files will be temporarily downloaded by Chrome.
|
| 102 |
-
output_path (str or Path): The directory where processed (renamed) CSV files will be saved.
|
| 103 |
-
min_sleep_time (float): Minimum random sleep time in seconds.
|
| 104 |
-
max_sleep_time (float): Maximum random sleep time in seconds.
|
| 105 |
-
max_retries (int): Maximum download retry attempts per element.
|
| 106 |
-
headless (bool): Run Chrome browser in headless mode.
|
| 107 |
-
"""
|
| 108 |
-
self.download_path = os.path.abspath(str(download_path))
|
| 109 |
-
self.output_path = os.path.abspath(str(output_path))
|
| 110 |
-
self.driver = None
|
| 111 |
-
self.wait = None
|
| 112 |
-
self.max_retries = max_retries
|
| 113 |
-
self.min_sleep_time = min_sleep_time
|
| 114 |
-
self.max_sleep_time = max_sleep_time
|
| 115 |
-
self.headless = headless
|
| 116 |
-
|
| 117 |
-
self._ensure_directories()
|
| 118 |
-
self._initialize_webdriver()
|
| 119 |
-
|
| 120 |
-
def _ensure_directories(self):
|
| 121 |
-
"""Ensures that the download and output directories exist."""
|
| 122 |
-
try:
|
| 123 |
-
os.makedirs(self.download_path, exist_ok=True)
|
| 124 |
-
os.makedirs(self.output_path, exist_ok=True)
|
| 125 |
-
except OSError as e:
|
| 126 |
-
print(f"Error creating directories: {e}. Please check permissions.")
|
| 127 |
-
raise
|
| 128 |
-
|
| 129 |
-
def _initialize_webdriver(self):
|
| 130 |
-
"""Initializes the Selenium WebDriver with Chrome options."""
|
| 131 |
-
if webdriver is None:
|
| 132 |
-
raise RuntimeError(
|
| 133 |
-
"Selenium or webdriver-manager is not installed in the Python environment. "
|
| 134 |
-
"Please run: pip install selenium webdriver-manager"
|
| 135 |
-
)
|
| 136 |
-
try:
|
| 137 |
-
chrome_options = Options()
|
| 138 |
-
prefs = {
|
| 139 |
-
"download.default_directory": self.download_path,
|
| 140 |
-
"download.prompt_for_download": False,
|
| 141 |
-
"download.directory_upgrade": True,
|
| 142 |
-
"plugins.always_open_pdf_externally": True
|
| 143 |
-
}
|
| 144 |
-
chrome_options.add_experimental_option("prefs", prefs)
|
| 145 |
-
if self.headless:
|
| 146 |
-
chrome_options.add_argument("--headless=new")
|
| 147 |
-
chrome_options.add_argument("--no-sandbox")
|
| 148 |
-
chrome_options.add_argument("--disable-dev-shm-usage")
|
| 149 |
-
chrome_options.add_argument("--window-size=1920,1080")
|
| 150 |
-
chrome_options.add_argument("--start-maximized")
|
| 151 |
-
chrome_options.add_argument("--disable-gpu")
|
| 152 |
-
chrome_options.add_argument("--log-level=3")
|
| 153 |
-
chrome_options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
|
| 154 |
-
chrome_options.add_argument("Accept-Language=en-US,en;q=0.9")
|
| 155 |
-
chrome_options.add_argument("Accept-Encoding=gzip, deflate, br")
|
| 156 |
-
chrome_options.add_argument("Accept=text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
| 157 |
-
chrome_options.add_argument("Connection=keep-alive")
|
| 158 |
-
chrome_options.add_argument("Upgrade-Insecure-Requests=1")
|
| 159 |
-
|
| 160 |
-
# Check for Linux system Chromium binary locations
|
| 161 |
-
for bin_path in ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"]:
|
| 162 |
-
if os.path.exists(bin_path):
|
| 163 |
-
chrome_options.binary_location = bin_path
|
| 164 |
-
break
|
| 165 |
-
|
| 166 |
-
try:
|
| 167 |
-
driver_path = ChromeDriverManager().install()
|
| 168 |
-
service = ChromeService(driver_path)
|
| 169 |
-
self.driver = webdriver.Chrome(service=service, options=chrome_options)
|
| 170 |
-
except Exception:
|
| 171 |
-
# Fallback to default system driver if ChromeDriverManager install fails
|
| 172 |
-
self.driver = webdriver.Chrome(options=chrome_options)
|
| 173 |
-
|
| 174 |
-
self.wait = WebDriverWait(self.driver, 30)
|
| 175 |
-
|
| 176 |
-
except WebDriverException as e:
|
| 177 |
-
print(f"Error initializing WebDriver: {e}")
|
| 178 |
-
raise
|
| 179 |
-
except Exception as e:
|
| 180 |
-
print(f"An unexpected error occurred during WebDriver initialization: {e}")
|
| 181 |
-
raise
|
| 182 |
-
|
| 183 |
-
def _wait_for_page_load_and_elements(self, url, download_button_xpath):
|
| 184 |
-
"""
|
| 185 |
-
Waits for the page to load by checking for the presence and clickability of key elements.
|
| 186 |
-
Additionally, introduces a random sleep time after elements are found.
|
| 187 |
-
"""
|
| 188 |
-
try:
|
| 189 |
-
self.wait.until(EC.presence_of_element_located((By.XPATH, "//img[@alt='csv']")))
|
| 190 |
-
self.wait.until(EC.element_to_be_clickable((By.XPATH, download_button_xpath)))
|
| 191 |
-
|
| 192 |
-
random_sleep_time = random.uniform(self.min_sleep_time, self.max_sleep_time) if self.max_sleep_time > 0 else 0.0
|
| 193 |
-
if random_sleep_time > 0:
|
| 194 |
-
time.sleep(random_sleep_time)
|
| 195 |
-
return True, random_sleep_time
|
| 196 |
-
except TimeoutException:
|
| 197 |
-
print(f" Timeout: Essential elements not found on {url}.")
|
| 198 |
-
return False, 0.0
|
| 199 |
-
except NoSuchElementException:
|
| 200 |
-
print(f" No such element: Required element not found on {url}.")
|
| 201 |
-
return False, 0.0
|
| 202 |
-
except Exception as e:
|
| 203 |
-
print(f" An error occurred while waiting for page elements on {url}: {e}")
|
| 204 |
-
return False, 0.0
|
| 205 |
-
|
| 206 |
-
def _wait_for_download_completion(self, expected_filename_prefix, timeout=10):
|
| 207 |
-
"""
|
| 208 |
-
Waits for a file to appear and complete downloading in the download directory.
|
| 209 |
-
Returns the full path of the downloaded file or None if timed out.
|
| 210 |
-
"""
|
| 211 |
-
start_time = time.time()
|
| 212 |
-
downloaded_file = None
|
| 213 |
-
current_size = -1
|
| 214 |
-
|
| 215 |
-
while time.time() - start_time < timeout:
|
| 216 |
-
try:
|
| 217 |
-
list_of_files = os.listdir(self.download_path)
|
| 218 |
-
csv_files = [f for f in list_of_files if f.endswith('.csv') or f.endswith('.crdownload')]
|
| 219 |
-
|
| 220 |
-
if not csv_files:
|
| 221 |
-
time.sleep(1)
|
| 222 |
-
continue
|
| 223 |
-
|
| 224 |
-
latest_file_path = max([os.path.join(self.download_path, f) for f in csv_files], key=os.path.getmtime)
|
| 225 |
-
latest_file_name = os.path.basename(latest_file_path)
|
| 226 |
-
|
| 227 |
-
if latest_file_name.endswith('.crdownload'):
|
| 228 |
-
current_size_new = os.path.getsize(latest_file_path)
|
| 229 |
-
if current_size_new == current_size:
|
| 230 |
-
downloaded_file = latest_file_path
|
| 231 |
-
break
|
| 232 |
-
current_size = current_size_new
|
| 233 |
-
time.sleep(1)
|
| 234 |
-
continue
|
| 235 |
-
|
| 236 |
-
if latest_file_name.endswith('.csv'):
|
| 237 |
-
downloaded_file = latest_file_path
|
| 238 |
-
break
|
| 239 |
-
|
| 240 |
-
except OSError as e:
|
| 241 |
-
print(f" OS Error while checking download directory: {e}")
|
| 242 |
-
time.sleep(1)
|
| 243 |
-
except Exception as e:
|
| 244 |
-
print(f" An unexpected error occurred during download check: {e}")
|
| 245 |
-
time.sleep(1)
|
| 246 |
-
|
| 247 |
-
time.sleep(1)
|
| 248 |
-
|
| 249 |
-
if downloaded_file:
|
| 250 |
-
return downloaded_file
|
| 251 |
-
else:
|
| 252 |
-
for f in os.listdir(self.download_path):
|
| 253 |
-
if f.endswith('.crdownload'):
|
| 254 |
-
try:
|
| 255 |
-
os.remove(os.path.join(self.download_path, f))
|
| 256 |
-
except OSError as e:
|
| 257 |
-
print(f" Error removing incomplete download {f}: {e}")
|
| 258 |
-
return None
|
| 259 |
-
|
| 260 |
-
def download_csv(self, name, url, js_function, download_button_xpath):
|
| 261 |
-
"""
|
| 262 |
-
Navigates to a URL, triggers a CSV download, and renames the downloaded file.
|
| 263 |
-
Includes retry logic and reports download metrics.
|
| 264 |
-
"""
|
| 265 |
-
successful_sleep_time = 0.0
|
| 266 |
-
|
| 267 |
-
for attempt in range(1, self.max_retries + 1):
|
| 268 |
-
print(f" Attempting to download {name} data... (Attempt {attempt}/{self.max_retries})", end='\r')
|
| 269 |
-
try:
|
| 270 |
-
if self.driver is None:
|
| 271 |
-
self._initialize_webdriver()
|
| 272 |
-
|
| 273 |
-
self.driver.get(url)
|
| 274 |
-
page_load_success, current_sleep_time = self._wait_for_page_load_and_elements(url, download_button_xpath)
|
| 275 |
-
if not page_load_success:
|
| 276 |
-
raise Exception("Page elements not ready.")
|
| 277 |
-
successful_sleep_time = current_sleep_time
|
| 278 |
-
|
| 279 |
-
self.driver.execute_script(js_function)
|
| 280 |
-
latest_downloaded_file = self._wait_for_download_completion(name)
|
| 281 |
-
|
| 282 |
-
if latest_downloaded_file:
|
| 283 |
-
new_filename = os.path.join(self.output_path, f"{name}.csv")
|
| 284 |
-
if os.path.exists(new_filename):
|
| 285 |
-
os.remove(new_filename)
|
| 286 |
-
os.rename(latest_downloaded_file, new_filename)
|
| 287 |
-
print(f" Successfully downloaded and saved {name} as: {new_filename} (Sleep Used: {round(successful_sleep_time, 2)}s)")
|
| 288 |
-
return True
|
| 289 |
-
else:
|
| 290 |
-
raise Exception("File download failed or timed out.")
|
| 291 |
-
|
| 292 |
-
except (WebDriverException, TimeoutException, NoSuchElementException,
|
| 293 |
-
ElementClickInterceptedException, Exception):
|
| 294 |
-
self.close()
|
| 295 |
-
time.sleep(2)
|
| 296 |
-
finally:
|
| 297 |
-
for f in os.listdir(self.download_path):
|
| 298 |
-
try:
|
| 299 |
-
os.remove(os.path.join(self.download_path, f))
|
| 300 |
-
except OSError as e:
|
| 301 |
-
print(f" Error cleaning up download directory: {e}")
|
| 302 |
-
|
| 303 |
-
print(f" Failed to download {name} data after {self.max_retries} attempts.")
|
| 304 |
-
return False
|
| 305 |
-
|
| 306 |
-
def close(self):
|
| 307 |
-
"""Closes the Selenium WebDriver session if it's open."""
|
| 308 |
-
try:
|
| 309 |
-
if self.driver:
|
| 310 |
-
self.driver.quit()
|
| 311 |
-
self.driver = None
|
| 312 |
-
except WebDriverException as e:
|
| 313 |
-
print(f"Error closing WebDriver: {e}")
|
| 314 |
-
except Exception as e:
|
| 315 |
-
print(f"An unexpected error occurred while closing the browser: {e}")
|
| 316 |
-
|
| 317 |
-
def scrape_all(self):
|
| 318 |
-
"""
|
| 319 |
-
Orchestrates the scraping of all defined data elements in a random order,
|
| 320 |
-
handling retries and reporting success/failure.
|
| 321 |
-
"""
|
| 322 |
-
results = {name: False for name in SCRAPE_ELEMENTS.keys()}
|
| 323 |
-
scrape_order = list(SCRAPE_ELEMENTS.keys())
|
| 324 |
-
random.shuffle(scrape_order)
|
| 325 |
-
|
| 326 |
-
print("\n--- Starting NSE Data Scraping Process ---")
|
| 327 |
-
print(f"Scraping order: {', '.join(scrape_order)}")
|
| 328 |
-
|
| 329 |
-
for name in scrape_order:
|
| 330 |
-
element_info = SCRAPE_ELEMENTS[name]
|
| 331 |
-
success = self.download_csv(
|
| 332 |
-
name,
|
| 333 |
-
element_info['url'],
|
| 334 |
-
element_info['js_function'],
|
| 335 |
-
element_info['download_button_xpath']
|
| 336 |
-
)
|
| 337 |
-
results[name] = success
|
| 338 |
-
if success:
|
| 339 |
-
self.close()
|
| 340 |
-
|
| 341 |
-
print("\n--- NSE Data Scraping Process Complete ---")
|
| 342 |
-
print("\nScraping Summary:")
|
| 343 |
-
for name, success in results.items():
|
| 344 |
-
status = "SUCCESS" if success else "FAILED"
|
| 345 |
-
print(f"- {name}: {status}")
|
| 346 |
-
return results
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
def process_csv_to_json(
|
| 350 |
-
input_csv_path: str,
|
| 351 |
-
output_json_path: str,
|
| 352 |
-
portfolio_name: str,
|
| 353 |
-
symbol_col: str,
|
| 354 |
-
sort_col: str,
|
| 355 |
-
sort_ascending: bool = False,
|
| 356 |
-
top_n: int = 50,
|
| 357 |
-
header_clean_type: str = "strip_quotes_and_spaces"
|
| 358 |
-
) -> None:
|
| 359 |
-
"""
|
| 360 |
-
Processes a CSV file, extracts top N symbols based on sorting criteria,
|
| 361 |
-
and saves them to a JSON file in the specified watchlist format.
|
| 362 |
-
|
| 363 |
-
Args:
|
| 364 |
-
input_csv_path (str): Path to the input CSV file.
|
| 365 |
-
output_json_path (str): Path to the output JSON file.
|
| 366 |
-
portfolio_name (str): Name of the portfolio for the JSON output.
|
| 367 |
-
symbol_col (str): The name of the column containing stock symbols.
|
| 368 |
-
sort_col (str): The name of the column to sort by.
|
| 369 |
-
sort_ascending (bool): True for ascending sort, False for descending.
|
| 370 |
-
top_n (int): Number of top symbols to extract.
|
| 371 |
-
header_clean_type (str): Type of header cleaning to perform.
|
| 372 |
-
"strip_quotes_and_spaces" for Volume.csv,
|
| 373 |
-
"strip_spaces" for Advance/Decline.csv.
|
| 374 |
-
"""
|
| 375 |
-
try:
|
| 376 |
-
df = pd.read_csv(input_csv_path)
|
| 377 |
-
|
| 378 |
-
cleaned_columns = []
|
| 379 |
-
for col in df.columns:
|
| 380 |
-
if header_clean_type == "strip_quotes_and_spaces":
|
| 381 |
-
cleaned_columns.append(col.strip().replace('"', ''))
|
| 382 |
-
elif header_clean_type == "strip_spaces":
|
| 383 |
-
cleaned_columns.append(col.strip())
|
| 384 |
-
else:
|
| 385 |
-
cleaned_columns.append(col)
|
| 386 |
-
|
| 387 |
-
df.columns = cleaned_columns
|
| 388 |
-
|
| 389 |
-
if sort_col not in df.columns:
|
| 390 |
-
print(f"Error: Sort column '{sort_col}' not found in {input_csv_path} after cleaning. Available columns: {df.columns.tolist()}")
|
| 391 |
-
return
|
| 392 |
-
if symbol_col not in df.columns:
|
| 393 |
-
print(f"Error: Symbol column '{symbol_col}' not found in {input_csv_path} after cleaning. Available columns: {df.columns.tolist()}")
|
| 394 |
-
return
|
| 395 |
-
|
| 396 |
-
df[sort_col] = pd.to_numeric(df[sort_col], errors='coerce')
|
| 397 |
-
df.dropna(subset=[sort_col], inplace=True)
|
| 398 |
-
|
| 399 |
-
df_sorted = df.sort_values(by=sort_col, ascending=sort_ascending)
|
| 400 |
-
top_symbols_df = df_sorted.head(top_n)
|
| 401 |
-
|
| 402 |
-
symbols_list = []
|
| 403 |
-
for _, row in top_symbols_df.iterrows():
|
| 404 |
-
symbols_list.append({
|
| 405 |
-
"SYMBOL": str(row[symbol_col]).strip().upper(),
|
| 406 |
-
"Quantity": 0.0,
|
| 407 |
-
"Price": 0.0
|
| 408 |
-
})
|
| 409 |
-
|
| 410 |
-
last_updated = datetime.now().isoformat()
|
| 411 |
-
|
| 412 |
-
json_output = {
|
| 413 |
-
"name": portfolio_name,
|
| 414 |
-
"symbols": symbols_list,
|
| 415 |
-
"last_updated": last_updated
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
output_dir = os.path.dirname(output_json_path)
|
| 419 |
-
if output_dir and not os.path.exists(output_dir):
|
| 420 |
-
os.makedirs(output_dir, exist_ok=True)
|
| 421 |
-
|
| 422 |
-
with open(output_json_path, 'w') as f:
|
| 423 |
-
json.dump(json_output, f, indent=2)
|
| 424 |
-
|
| 425 |
-
print(f"Processing completed: {os.path.basename(output_json_path)}")
|
| 426 |
-
|
| 427 |
-
except FileNotFoundError:
|
| 428 |
-
print(f"Error: Input file not found at {input_csv_path}")
|
| 429 |
-
except KeyError as e:
|
| 430 |
-
print(f"Error: Missing expected column in {input_csv_path}: {e}")
|
| 431 |
-
except Exception as e:
|
| 432 |
-
print(f"An unexpected error occurred while processing {input_csv_path}: {e}")
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def main():
|
| 436 |
-
parser = argparse.ArgumentParser(description="NSE Max Live Data Scraper and Watchlist Processor")
|
| 437 |
-
parser.add_argument(
|
| 438 |
-
"-o", "--output-dir",
|
| 439 |
-
type=str,
|
| 440 |
-
default=str(DEFAULT_OUTPUT_DIR),
|
| 441 |
-
help=f"Target directory for output CSVs & watchlists (default: {DEFAULT_OUTPUT_DIR})"
|
| 442 |
-
)
|
| 443 |
-
parser.add_argument(
|
| 444 |
-
"-t", "--temp-dir",
|
| 445 |
-
type=str,
|
| 446 |
-
default=str(DEFAULT_DOWNLOAD_DIR),
|
| 447 |
-
help=f"Temporary download directory for Chrome (default: {DEFAULT_DOWNLOAD_DIR})"
|
| 448 |
-
)
|
| 449 |
-
parser.add_argument(
|
| 450 |
-
"--min-sleep",
|
| 451 |
-
type=float,
|
| 452 |
-
default=0.0,
|
| 453 |
-
help="Minimum sleep time during element wait (seconds)"
|
| 454 |
-
)
|
| 455 |
-
parser.add_argument(
|
| 456 |
-
"--max-sleep",
|
| 457 |
-
type=float,
|
| 458 |
-
default=0.0,
|
| 459 |
-
help="Maximum sleep time during element wait (seconds)"
|
| 460 |
-
)
|
| 461 |
-
parser.add_argument(
|
| 462 |
-
"--retries",
|
| 463 |
-
type=int,
|
| 464 |
-
default=5,
|
| 465 |
-
help="Maximum retry attempts per URL download (default: 5)"
|
| 466 |
-
)
|
| 467 |
-
parser.add_argument(
|
| 468 |
-
"--no-headless",
|
| 469 |
-
action="store_true",
|
| 470 |
-
help="Run Chrome browser in visible mode (default: headless)"
|
| 471 |
-
)
|
| 472 |
-
|
| 473 |
-
args = parser.parse_args()
|
| 474 |
-
|
| 475 |
-
print_splash_screen()
|
| 476 |
-
|
| 477 |
-
download_folder = os.path.abspath(args.temp_dir)
|
| 478 |
-
output_folder = os.path.abspath(args.output_dir)
|
| 479 |
-
|
| 480 |
-
scraper = None
|
| 481 |
-
try:
|
| 482 |
-
scraper = NseDataScraper(
|
| 483 |
-
download_path=download_folder,
|
| 484 |
-
output_path=output_folder,
|
| 485 |
-
min_sleep_time=args.min_sleep,
|
| 486 |
-
max_sleep_time=args.max_sleep,
|
| 487 |
-
max_retries=args.retries,
|
| 488 |
-
headless=not args.no_headless
|
| 489 |
-
)
|
| 490 |
-
scrape_results = scraper.scrape_all()
|
| 491 |
-
|
| 492 |
-
output_base_dir = os.path.join(output_folder, "watchlists")
|
| 493 |
-
os.makedirs(output_base_dir, exist_ok=True)
|
| 494 |
-
|
| 495 |
-
print("\n--- Starting CSV Processing to JSON ---")
|
| 496 |
-
|
| 497 |
-
# 1. Process Volume.csv -> nse_dashboard_maxvol50.json
|
| 498 |
-
if scrape_results.get('Volume'):
|
| 499 |
-
process_csv_to_json(
|
| 500 |
-
input_csv_path=os.path.join(output_folder, "Volume.csv"),
|
| 501 |
-
output_json_path=os.path.join(output_base_dir, "nse_dashboard_maxvol50.json"),
|
| 502 |
-
portfolio_name="Max Volume Portfolio",
|
| 503 |
-
symbol_col="SYMBOL",
|
| 504 |
-
sort_col="TODAY - VOLUME",
|
| 505 |
-
sort_ascending=False,
|
| 506 |
-
top_n=50,
|
| 507 |
-
header_clean_type="strip_quotes_and_spaces"
|
| 508 |
-
)
|
| 509 |
-
else:
|
| 510 |
-
print("Skipping Volume.csv processing as it was not successfully scraped.")
|
| 511 |
-
|
| 512 |
-
# 2. Process Advance.csv -> nse_dashboard_maxprice50.json
|
| 513 |
-
if scrape_results.get('Advance'):
|
| 514 |
-
process_csv_to_json(
|
| 515 |
-
input_csv_path=os.path.join(output_folder, "Advance.csv"),
|
| 516 |
-
output_json_path=os.path.join(output_base_dir, "nse_dashboard_maxprice50.json"),
|
| 517 |
-
portfolio_name="Max Price Portfolio",
|
| 518 |
-
symbol_col="Symbol",
|
| 519 |
-
sort_col="%chng",
|
| 520 |
-
sort_ascending=False,
|
| 521 |
-
top_n=50,
|
| 522 |
-
header_clean_type="strip_spaces"
|
| 523 |
-
)
|
| 524 |
-
else:
|
| 525 |
-
print("Skipping Advance.csv processing as it was not successfully scraped.")
|
| 526 |
-
|
| 527 |
-
# 3. Process Decline.csv -> nse_dashboard_maxfall50.json
|
| 528 |
-
if scrape_results.get('Decline'):
|
| 529 |
-
process_csv_to_json(
|
| 530 |
-
input_csv_path=os.path.join(output_folder, "Decline.csv"),
|
| 531 |
-
output_json_path=os.path.join(output_base_dir, "nse_dashboard_maxfall50.json"),
|
| 532 |
-
portfolio_name="Max Fall Portfolio",
|
| 533 |
-
symbol_col="Symbol",
|
| 534 |
-
sort_col="%chng",
|
| 535 |
-
sort_ascending=True,
|
| 536 |
-
top_n=50,
|
| 537 |
-
header_clean_type="strip_spaces"
|
| 538 |
-
)
|
| 539 |
-
else:
|
| 540 |
-
print("Skipping Decline.csv processing as it was not successfully scraped.")
|
| 541 |
-
|
| 542 |
-
print("\n--- CSV Processing to JSON Complete ---")
|
| 543 |
-
|
| 544 |
-
except Exception as e:
|
| 545 |
-
print(f"\nAn unrecoverable error occurred during the overall process: {e}")
|
| 546 |
-
finally:
|
| 547 |
-
if scraper:
|
| 548 |
-
scraper.close()
|
| 549 |
-
print("\n--- Program Exited ---")
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
if __name__ == "__main__":
|
| 553 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsemaxmin.py
DELETED
|
@@ -1,147 +0,0 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
import json
|
| 3 |
-
import os
|
| 4 |
-
from datetime import datetime
|
| 5 |
-
|
| 6 |
-
def check_and_convert_csv_to_parquet(csv_path: str, parquet_path: str) -> None:
|
| 7 |
-
"""If a CSV file is found, convert it to Parquet and delete the CSV."""
|
| 8 |
-
if os.path.exists(csv_path):
|
| 9 |
-
try:
|
| 10 |
-
df = pd.read_csv(csv_path)
|
| 11 |
-
df.to_parquet(parquet_path, index=False)
|
| 12 |
-
os.remove(csv_path)
|
| 13 |
-
print(f"Auto-converted {csv_path} to {parquet_path} and removed CSV.")
|
| 14 |
-
except Exception as e:
|
| 15 |
-
print(f"Failed to auto-convert {csv_path} to Parquet: {e}")
|
| 16 |
-
|
| 17 |
-
def process_parquet_to_json(
|
| 18 |
-
input_parquet_path: str,
|
| 19 |
-
output_json_path: str,
|
| 20 |
-
portfolio_name: str,
|
| 21 |
-
symbol_col: str,
|
| 22 |
-
sort_col: str,
|
| 23 |
-
sort_ascending: bool = False,
|
| 24 |
-
top_n: int = 50,
|
| 25 |
-
header_clean_type: str = "strip_quotes_and_spaces" # "strip_quotes_and_spaces" or "strip_spaces"
|
| 26 |
-
) -> None:
|
| 27 |
-
"""
|
| 28 |
-
Processes a Parquet file, extracts top N symbols based on sorting criteria,
|
| 29 |
-
and saves them to a JSON file in the specified format.
|
| 30 |
-
"""
|
| 31 |
-
# Check for CSV format and convert to Parquet if found
|
| 32 |
-
csv_path = input_parquet_path.replace(".parquet", ".csv")
|
| 33 |
-
check_and_convert_csv_to_parquet(csv_path, input_parquet_path)
|
| 34 |
-
|
| 35 |
-
try:
|
| 36 |
-
df = pd.read_parquet(input_parquet_path)
|
| 37 |
-
|
| 38 |
-
# Clean up column names based on the specified type
|
| 39 |
-
cleaned_columns = []
|
| 40 |
-
for col in df.columns:
|
| 41 |
-
if header_clean_type == "strip_quotes_and_spaces":
|
| 42 |
-
cleaned_columns.append(col.strip().replace('"', ''))
|
| 43 |
-
elif header_clean_type == "strip_spaces":
|
| 44 |
-
cleaned_columns.append(col.strip())
|
| 45 |
-
else:
|
| 46 |
-
cleaned_columns.append(col) # No cleaning
|
| 47 |
-
|
| 48 |
-
df.columns = cleaned_columns
|
| 49 |
-
|
| 50 |
-
# Ensure sort_col and symbol_col exist after cleaning
|
| 51 |
-
if sort_col not in df.columns:
|
| 52 |
-
print(f"Error: Sort column '{sort_col}' not found in {input_parquet_path} after cleaning. Available columns: {df.columns.tolist()}")
|
| 53 |
-
return
|
| 54 |
-
if symbol_col not in df.columns:
|
| 55 |
-
print(f"Error: Symbol column '{symbol_col}' not found in {input_parquet_path} after cleaning. Available columns: {df.columns.tolist()}")
|
| 56 |
-
return
|
| 57 |
-
|
| 58 |
-
# Convert sort_col to numeric, handling errors by coercing to NaN
|
| 59 |
-
df[sort_col] = pd.to_numeric(df[sort_col], errors='coerce')
|
| 60 |
-
# Drop rows where sort_col is NaN (if conversion failed)
|
| 61 |
-
df.dropna(subset=[sort_col], inplace=True)
|
| 62 |
-
|
| 63 |
-
# Sort the DataFrame
|
| 64 |
-
df_sorted = df.sort_values(by=sort_col, ascending=sort_ascending)
|
| 65 |
-
|
| 66 |
-
# Select the top N symbols
|
| 67 |
-
top_symbols_df = df_sorted.head(top_n)
|
| 68 |
-
|
| 69 |
-
# Prepare the list of symbols for JSON output
|
| 70 |
-
symbols_list = []
|
| 71 |
-
for _, row in top_symbols_df.iterrows():
|
| 72 |
-
symbols_list.append({
|
| 73 |
-
"SYMBOL": row[symbol_col],
|
| 74 |
-
"Quantity": 0.0,
|
| 75 |
-
"Price": 0.0
|
| 76 |
-
})
|
| 77 |
-
|
| 78 |
-
# Get current timestamp
|
| 79 |
-
last_updated = datetime.now().isoformat()
|
| 80 |
-
|
| 81 |
-
# Create the final JSON structure
|
| 82 |
-
json_output = {
|
| 83 |
-
"name": portfolio_name,
|
| 84 |
-
"symbols": symbols_list,
|
| 85 |
-
"last_updated": last_updated
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
# Create output directory if it doesn't exist
|
| 89 |
-
output_dir = os.path.dirname(output_json_path)
|
| 90 |
-
if output_dir and not os.path.exists(output_dir):
|
| 91 |
-
os.makedirs(output_dir)
|
| 92 |
-
|
| 93 |
-
# Write to JSON file
|
| 94 |
-
with open(output_json_path, 'w') as f:
|
| 95 |
-
json.dump(json_output, f, indent=2)
|
| 96 |
-
|
| 97 |
-
print(f"Successfully processed {input_parquet_path} and saved to {output_json_path}")
|
| 98 |
-
|
| 99 |
-
except FileNotFoundError:
|
| 100 |
-
print(f"Error: Input file not found at {input_parquet_path}")
|
| 101 |
-
except KeyError as e:
|
| 102 |
-
print(f"Error: Missing expected column in {input_parquet_path}: {e}")
|
| 103 |
-
except Exception as e:
|
| 104 |
-
print(f"An unexpected error occurred while processing {input_parquet_path}: {e}")
|
| 105 |
-
|
| 106 |
-
# --- Main execution ---
|
| 107 |
-
if __name__ == "__main__":
|
| 108 |
-
# Ensure the 'processed/watchlists' directory exists
|
| 109 |
-
output_base_dir = "./processed/watchlists"
|
| 110 |
-
if not os.path.exists(output_base_dir):
|
| 111 |
-
os.makedirs(output_base_dir)
|
| 112 |
-
|
| 113 |
-
# 1. Process Volume.parquet
|
| 114 |
-
process_parquet_to_json(
|
| 115 |
-
input_parquet_path="./processed/Volume.parquet",
|
| 116 |
-
output_json_path="./processed/watchlists/nse_dashboard_maxvol50.json",
|
| 117 |
-
portfolio_name="Max Volume Portfolio",
|
| 118 |
-
symbol_col="SYMBOL",
|
| 119 |
-
sort_col="TODAY - VOLUME",
|
| 120 |
-
sort_ascending=False, # Descending for Max Volume
|
| 121 |
-
top_n=50,
|
| 122 |
-
header_clean_type="strip_quotes_and_spaces"
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
# 2. Process Advance.parquet
|
| 126 |
-
process_parquet_to_json(
|
| 127 |
-
input_parquet_path="./processed/Advance.parquet",
|
| 128 |
-
output_json_path="./processed/watchlists/nse_dashboard_maxprice50.json",
|
| 129 |
-
portfolio_name="Max Price Portfolio",
|
| 130 |
-
symbol_col="Symbol", # Note the exact column name 'Symbol' with initial cap
|
| 131 |
-
sort_col="%chng",
|
| 132 |
-
sort_ascending=False, # Descending for Max Price Change
|
| 133 |
-
top_n=50,
|
| 134 |
-
header_clean_type="strip_spaces"
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
# 3. Process Decline.parquet
|
| 138 |
-
process_parquet_to_json(
|
| 139 |
-
input_parquet_path="./processed/Decline.parquet",
|
| 140 |
-
output_json_path="./processed/watchlists/nse_dashboard_maxfall50.json",
|
| 141 |
-
portfolio_name="Max Fall Portfolio",
|
| 142 |
-
symbol_col="Symbol", # Note the exact column name 'Symbol' with initial cap
|
| 143 |
-
sort_col="%chng",
|
| 144 |
-
sort_ascending=True, # Ascending for Max Fall (most negative %chng)
|
| 145 |
-
top_n=50,
|
| 146 |
-
header_clean_type="strip_spaces"
|
| 147 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsemcupdate.py
DELETED
|
@@ -1,418 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Moneycontrol Data Updater Utility (nsemcupdate.py)
|
| 4 |
-
|
| 5 |
-
This script maps active NSE symbols in the database to their Moneycontrol pages
|
| 6 |
-
and scrapes/updates financial metrics (MC_SCORE, MC_MCAP, MC_FV, MC_ATH, MC_ATL)
|
| 7 |
-
in the nse_dashboard_mclink.json file.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
import os
|
| 11 |
-
import sys
|
| 12 |
-
import re
|
| 13 |
-
import json
|
| 14 |
-
import time
|
| 15 |
-
import random
|
| 16 |
-
import logging
|
| 17 |
-
import argparse
|
| 18 |
-
from pathlib import Path
|
| 19 |
-
import requests
|
| 20 |
-
from bs4 import BeautifulSoup
|
| 21 |
-
|
| 22 |
-
# Setup basic logging
|
| 23 |
-
logging.basicConfig(
|
| 24 |
-
level=logging.INFO,
|
| 25 |
-
format='%(asctime)s [%(levelname)s] %(message)s',
|
| 26 |
-
handlers=[
|
| 27 |
-
logging.StreamHandler(sys.stdout)
|
| 28 |
-
]
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
USER_AGENTS = [
|
| 32 |
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
| 33 |
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
| 34 |
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0',
|
| 35 |
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
|
| 36 |
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0'
|
| 37 |
-
]
|
| 38 |
-
|
| 39 |
-
def get_headers():
|
| 40 |
-
return {
|
| 41 |
-
'User-Agent': random.choice(USER_AGENTS),
|
| 42 |
-
'Accept-Language': 'en-US,en;q=0.9',
|
| 43 |
-
'Accept-Encoding': 'gzip, deflate, br',
|
| 44 |
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
|
| 45 |
-
'Connection': 'keep-alive',
|
| 46 |
-
'Referer': 'https://www.moneycontrol.com/',
|
| 47 |
-
'Upgrade-Insecure-Requests': '1'
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
def normalize_name(name):
|
| 51 |
-
if not name:
|
| 52 |
-
return ""
|
| 53 |
-
name = name.lower()
|
| 54 |
-
# Remove common corporate suffixes
|
| 55 |
-
name = re.sub(r'\b(ltd|limited|co|corp|corporation|ind|industries|india|limited\b|&)\b', '', name)
|
| 56 |
-
# Remove all non-alphanumeric characters
|
| 57 |
-
return re.sub(r'[^a-z0-9]', '', name)
|
| 58 |
-
|
| 59 |
-
def parse_float(val_str):
|
| 60 |
-
if not val_str:
|
| 61 |
-
return 0.0
|
| 62 |
-
try:
|
| 63 |
-
cleaned = re.sub(r'[^\d.]', '', val_str.strip())
|
| 64 |
-
return float(cleaned) if cleaned else 0.0
|
| 65 |
-
except Exception:
|
| 66 |
-
return 0.0
|
| 67 |
-
|
| 68 |
-
def scrape_directory_pages(cache_path, delay=2.0):
|
| 69 |
-
"""Scrapes A-Z and others directories and caches the links list."""
|
| 70 |
-
logging.info("Starting Moneycontrol directory index pages scan...")
|
| 71 |
-
letters = [chr(c) for c in range(ord('A'), ord('Z') + 1)] + ['others']
|
| 72 |
-
all_links = []
|
| 73 |
-
|
| 74 |
-
for index, letter in enumerate(letters):
|
| 75 |
-
url = f'https://www.moneycontrol.com/india/stockpricequote/{letter}'
|
| 76 |
-
logging.info(f"[{index + 1}/{len(letters)}] Scanning index page: {url}")
|
| 77 |
-
|
| 78 |
-
try:
|
| 79 |
-
r = requests.get(url, headers=get_headers(), timeout=15)
|
| 80 |
-
r.raise_for_status()
|
| 81 |
-
except Exception as e:
|
| 82 |
-
logging.error(f"Error fetching directory page {url}: {e}")
|
| 83 |
-
time.sleep(delay)
|
| 84 |
-
continue
|
| 85 |
-
|
| 86 |
-
soup = BeautifulSoup(r.text, 'html.parser')
|
| 87 |
-
links = soup.select('a.bl_12')
|
| 88 |
-
page_count = 0
|
| 89 |
-
|
| 90 |
-
for l in links:
|
| 91 |
-
href = l.get('href')
|
| 92 |
-
text = l.text.strip()
|
| 93 |
-
if href and 'https://www.moneycontrol.com/india/stockpricequote/' in href:
|
| 94 |
-
path_parts = [p for p in href.split('/') if p]
|
| 95 |
-
if len(path_parts) >= 2:
|
| 96 |
-
mc_code = path_parts[-1].strip()
|
| 97 |
-
mc_segment = path_parts[-2].strip()
|
| 98 |
-
all_links.append({
|
| 99 |
-
"company_name": text,
|
| 100 |
-
"mc_link": href,
|
| 101 |
-
"mc_company_code": mc_code,
|
| 102 |
-
"mc_url_segment": mc_segment
|
| 103 |
-
})
|
| 104 |
-
page_count += 1
|
| 105 |
-
|
| 106 |
-
logging.info(f" -> Extracted {page_count} company links from page '{letter}'")
|
| 107 |
-
time.sleep(delay + random.uniform(0.5, 1.5))
|
| 108 |
-
|
| 109 |
-
# Save to cache
|
| 110 |
-
try:
|
| 111 |
-
with open(cache_path, 'w') as f:
|
| 112 |
-
json.dump(all_links, f, indent=4)
|
| 113 |
-
logging.info(f"Directory cache saved to: {cache_path}")
|
| 114 |
-
except Exception as e:
|
| 115 |
-
logging.error(f"Failed to save directory cache: {e}")
|
| 116 |
-
|
| 117 |
-
return all_links
|
| 118 |
-
|
| 119 |
-
def load_directory_cache(cache_path, delay=2.0):
|
| 120 |
-
if cache_path.exists():
|
| 121 |
-
try:
|
| 122 |
-
with open(cache_path, 'r') as f:
|
| 123 |
-
return json.load(f)
|
| 124 |
-
except Exception as e:
|
| 125 |
-
logging.warning(f"Failed to load directory cache: {e}. Re-scraping...")
|
| 126 |
-
return scrape_directory_pages(cache_path, delay)
|
| 127 |
-
|
| 128 |
-
def get_nse_symbol_from_page(soup):
|
| 129 |
-
"""Robustly extracts the NSE ticker symbol from the HTML page."""
|
| 130 |
-
# Heuristic 1: clearfix list items
|
| 131 |
-
for li in soup.find_all('li'):
|
| 132 |
-
span = li.find('span')
|
| 133 |
-
p = li.find('p')
|
| 134 |
-
if span and p and span.text.strip().lower().rstrip(':') == 'nse':
|
| 135 |
-
return p.text.strip().upper()
|
| 136 |
-
|
| 137 |
-
# Heuristic 2: search for NSE label string
|
| 138 |
-
for tag in soup.find_all(string=lambda x: x and x.strip().lower().rstrip(':') == 'nse'):
|
| 139 |
-
parent = tag.parent
|
| 140 |
-
sibling = parent.find_next_sibling()
|
| 141 |
-
if sibling:
|
| 142 |
-
return sibling.text.strip().upper()
|
| 143 |
-
|
| 144 |
-
# Heuristic 3: JSON script section at the bottom of the page
|
| 145 |
-
for script in soup.find_all('script'):
|
| 146 |
-
if script.string and 'nseId' in script.string:
|
| 147 |
-
match = re.search(r'"nseId"\s*:\s*"([^"]+)"', script.string)
|
| 148 |
-
if match:
|
| 149 |
-
return match.group(1).strip().upper()
|
| 150 |
-
|
| 151 |
-
return None
|
| 152 |
-
|
| 153 |
-
def scrape_stock_details(mc_link):
|
| 154 |
-
"""Scrapes detail metrics and returns verified details or None."""
|
| 155 |
-
try:
|
| 156 |
-
r = requests.get(mc_link, headers=get_headers(), timeout=15)
|
| 157 |
-
r.raise_for_status()
|
| 158 |
-
except Exception as e:
|
| 159 |
-
logging.warning(f"Request failed for {mc_link}: {e}")
|
| 160 |
-
return None
|
| 161 |
-
|
| 162 |
-
soup = BeautifulSoup(r.text, 'html.parser')
|
| 163 |
-
|
| 164 |
-
# Extract & verify NSE symbol
|
| 165 |
-
nse_symbol = get_nse_symbol_from_page(soup)
|
| 166 |
-
|
| 167 |
-
# Scrape metrics
|
| 168 |
-
score_div = soup.find(class_='score') or soup.select_one('div[class*="score"]')
|
| 169 |
-
mc_score = 0.0
|
| 170 |
-
if score_div:
|
| 171 |
-
match = re.search(r'(\d+)', score_div.text)
|
| 172 |
-
if match:
|
| 173 |
-
mc_score = float(match.group(1))
|
| 174 |
-
|
| 175 |
-
mcap_td = soup.select_one('td.nsemktcap.bsemktcap')
|
| 176 |
-
mc_mcap = parse_float(mcap_td.text) if mcap_td else 0.0
|
| 177 |
-
|
| 178 |
-
fv_td = soup.select_one('td.nsefv.bsefv')
|
| 179 |
-
mc_fv = parse_float(fv_td.text) if fv_td else 0.0
|
| 180 |
-
|
| 181 |
-
ath_td = soup.select_one('td.nseLTH.bseLTH') or soup.select_one('td.nseH52.bseH52')
|
| 182 |
-
mc_ath = parse_float(ath_td.text) if ath_td else 0.0
|
| 183 |
-
|
| 184 |
-
atl_td = soup.select_one('td.nseLTL.bseLTL') or soup.select_one('td.nseL52.bseL52')
|
| 185 |
-
mc_atl = parse_float(atl_td.text) if atl_td else 0.0
|
| 186 |
-
|
| 187 |
-
return {
|
| 188 |
-
"NSE_SYMBOL": nse_symbol,
|
| 189 |
-
"MC_SCORE": mc_score,
|
| 190 |
-
"MC_MCAP": mc_mcap,
|
| 191 |
-
"MC_FV": mc_fv,
|
| 192 |
-
"MC_ATH": mc_ath,
|
| 193 |
-
"MC_ATL": mc_atl
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
def main():
|
| 197 |
-
parser = argparse.ArgumentParser(description="Update Moneycontrol related information for NSE stocks.")
|
| 198 |
-
parser.add_argument("--base-dir", help="Base directory containing processed stock data.")
|
| 199 |
-
parser.add_argument("--update-all", action="store_true", help="Force update all stocks, even those already successfully matched.")
|
| 200 |
-
parser.add_argument("--delay", type=float, default=2.0, help="Mean delay between network requests to prevent rate blocking.")
|
| 201 |
-
parser.add_argument("--limit", type=int, help="Limit the number of stocks scraped in this run.")
|
| 202 |
-
args = parser.parse_args()
|
| 203 |
-
|
| 204 |
-
# Initialize Paths config
|
| 205 |
-
# We append workspace path to sys.path so we can import nsedash.config
|
| 206 |
-
workspace_dir = Path(__file__).resolve().parent
|
| 207 |
-
sys.path.insert(0, str(workspace_dir))
|
| 208 |
-
|
| 209 |
-
try:
|
| 210 |
-
from nsedash.config import Paths
|
| 211 |
-
except ImportError as e:
|
| 212 |
-
logging.error(f"Failed to import config. Ensure you run this script from the workspace root: {e}")
|
| 213 |
-
sys.exit(1)
|
| 214 |
-
|
| 215 |
-
paths = Paths(base_dir=args.base_dir)
|
| 216 |
-
logging.info(f"Initialized base data directory: {paths.base_dir}")
|
| 217 |
-
|
| 218 |
-
# Load active symbols from parquet
|
| 219 |
-
if not paths.input_file.exists():
|
| 220 |
-
logging.error(f"Active stock processed data file not found: {paths.input_file}")
|
| 221 |
-
sys.exit(1)
|
| 222 |
-
|
| 223 |
-
import pandas as pd
|
| 224 |
-
try:
|
| 225 |
-
df = pd.read_parquet(paths.input_file)
|
| 226 |
-
active_companies = df[['SYMBOL', 'SECURITY']].drop_duplicates().to_dict('records')
|
| 227 |
-
logging.info(f"Loaded {len(active_companies)} unique active stocks from database.")
|
| 228 |
-
except Exception as e:
|
| 229 |
-
logging.error(f"Failed to load active symbols: {e}")
|
| 230 |
-
sys.exit(1)
|
| 231 |
-
|
| 232 |
-
# Load existing links
|
| 233 |
-
mc_links = {}
|
| 234 |
-
if paths.mc_link_file.exists():
|
| 235 |
-
try:
|
| 236 |
-
with open(paths.mc_link_file, 'r') as f:
|
| 237 |
-
mc_links = json.load(f)
|
| 238 |
-
logging.info(f"Loaded {len(mc_links)} existing Moneycontrol links from: {paths.mc_link_file.name}")
|
| 239 |
-
except Exception as e:
|
| 240 |
-
logging.error(f"Failed to load mc_link file: {e}")
|
| 241 |
-
|
| 242 |
-
# Determine which stocks need lookup or update
|
| 243 |
-
stocks_to_update = []
|
| 244 |
-
for item in active_companies:
|
| 245 |
-
symbol = str(item['SYMBOL']).strip().upper()
|
| 246 |
-
security = str(item['SECURITY']).strip()
|
| 247 |
-
|
| 248 |
-
# Check if already processed
|
| 249 |
-
details = mc_links.get(symbol, {})
|
| 250 |
-
has_link = 'MC_LINK' in details and details['MC_LINK']
|
| 251 |
-
is_completed = details.get('MC_STATUS', False)
|
| 252 |
-
|
| 253 |
-
if args.update_all or not has_link or not is_completed:
|
| 254 |
-
stocks_to_update.append({
|
| 255 |
-
"symbol": symbol,
|
| 256 |
-
"security": security,
|
| 257 |
-
"existing_details": details
|
| 258 |
-
})
|
| 259 |
-
|
| 260 |
-
if not stocks_to_update:
|
| 261 |
-
logging.info("All stocks are already up-to-date. Nothing to do!")
|
| 262 |
-
sys.exit(0)
|
| 263 |
-
|
| 264 |
-
# Filter out limit
|
| 265 |
-
if args.limit:
|
| 266 |
-
stocks_to_update = stocks_to_update[:args.limit]
|
| 267 |
-
logging.info(f"Limiting scan to first {args.limit} stocks.")
|
| 268 |
-
|
| 269 |
-
logging.info(f"Total stocks to update in this session: {len(stocks_to_update)}")
|
| 270 |
-
|
| 271 |
-
# Load directory listings (cache or scrape)
|
| 272 |
-
cache_path = paths.data_dir / "nse_mc_directory_cache.json"
|
| 273 |
-
mc_directory = load_directory_cache(cache_path, args.delay)
|
| 274 |
-
logging.info(f"Moneycontrol directory loaded with {len(mc_directory)} links.")
|
| 275 |
-
|
| 276 |
-
# Match missing links
|
| 277 |
-
matched_count = 0
|
| 278 |
-
scraped_count = 0
|
| 279 |
-
start_time = time.time()
|
| 280 |
-
|
| 281 |
-
try:
|
| 282 |
-
for idx, stock in enumerate(stocks_to_update):
|
| 283 |
-
symbol = stock["symbol"]
|
| 284 |
-
security = stock["security"]
|
| 285 |
-
existing = stock["existing_details"]
|
| 286 |
-
|
| 287 |
-
logging.info(f"[{idx+1}/{len(stocks_to_update)}] Processing: {symbol} - {security}")
|
| 288 |
-
|
| 289 |
-
mc_link = existing.get("MC_LINK")
|
| 290 |
-
mc_code = existing.get("MC_COMPANY_CODE")
|
| 291 |
-
mc_name = existing.get("MC_COMPANY_NAME", security)
|
| 292 |
-
|
| 293 |
-
# 1. Resolve Link if missing
|
| 294 |
-
if not mc_link:
|
| 295 |
-
# Find candidates using heuristics
|
| 296 |
-
candidates = []
|
| 297 |
-
norm_sec = normalize_name(security)
|
| 298 |
-
norm_sym = normalize_name(symbol)
|
| 299 |
-
|
| 300 |
-
for entry in mc_directory:
|
| 301 |
-
c_name = entry["company_name"]
|
| 302 |
-
c_code = entry["mc_company_code"]
|
| 303 |
-
c_seg = entry["mc_url_segment"]
|
| 304 |
-
c_link = entry["mc_link"]
|
| 305 |
-
|
| 306 |
-
norm_c_name = normalize_name(c_name)
|
| 307 |
-
norm_c_seg = normalize_name(c_seg)
|
| 308 |
-
|
| 309 |
-
# Heuristics checks
|
| 310 |
-
if norm_sym == normalize_name(c_code):
|
| 311 |
-
candidates.append((10, c_link, c_code, c_name))
|
| 312 |
-
elif norm_sec == norm_c_name or norm_sec == norm_c_seg:
|
| 313 |
-
candidates.append((9, c_link, c_code, c_name))
|
| 314 |
-
elif norm_sec in norm_c_name or norm_c_name in norm_sec:
|
| 315 |
-
candidates.append((8, c_link, c_code, c_name))
|
| 316 |
-
elif norm_sec in norm_c_seg or norm_c_seg in norm_sec:
|
| 317 |
-
candidates.append((7, c_link, c_code, c_name))
|
| 318 |
-
elif norm_sym in norm_c_name or norm_sym in norm_c_seg:
|
| 319 |
-
candidates.append((5, c_link, c_code, c_name))
|
| 320 |
-
|
| 321 |
-
# Sort by priority score descending
|
| 322 |
-
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 323 |
-
|
| 324 |
-
# Try candidate links
|
| 325 |
-
found = False
|
| 326 |
-
for priority, c_link, c_code, c_name in candidates:
|
| 327 |
-
logging.info(f" Trying candidate URL ({priority} pts): {c_link}")
|
| 328 |
-
|
| 329 |
-
# Scraping & Verification
|
| 330 |
-
time.sleep(args.delay + random.uniform(0.1, 1.0))
|
| 331 |
-
details = scrape_stock_details(c_link)
|
| 332 |
-
scraped_count += 1
|
| 333 |
-
|
| 334 |
-
if details:
|
| 335 |
-
extracted_sym = details["NSE_SYMBOL"]
|
| 336 |
-
if extracted_sym == symbol:
|
| 337 |
-
logging.info(f" --> Verified match! NSE symbol matches: {symbol}")
|
| 338 |
-
mc_link = c_link
|
| 339 |
-
mc_code = c_code
|
| 340 |
-
mc_name = c_name
|
| 341 |
-
|
| 342 |
-
# Merge details
|
| 343 |
-
mc_links[symbol] = {
|
| 344 |
-
"MC_LINK": mc_link,
|
| 345 |
-
"MC_COMPANY_CODE": mc_code,
|
| 346 |
-
"MC_COMPANY_NAME": mc_name,
|
| 347 |
-
"MC_SCORE": details["MC_SCORE"],
|
| 348 |
-
"MC_MCAP": details["MC_MCAP"],
|
| 349 |
-
"MC_FV": details["MC_FV"],
|
| 350 |
-
"MC_ATH": details["MC_ATH"],
|
| 351 |
-
"MC_ATL": details["MC_ATL"],
|
| 352 |
-
"MC_STATUS": True
|
| 353 |
-
}
|
| 354 |
-
matched_count += 1
|
| 355 |
-
found = True
|
| 356 |
-
break
|
| 357 |
-
else:
|
| 358 |
-
logging.info(f" --> Rejected. Page NSE symbol was: {extracted_sym}")
|
| 359 |
-
|
| 360 |
-
if not found:
|
| 361 |
-
logging.warning(f" No verified Moneycontrol URL found for: {symbol}")
|
| 362 |
-
# If it had existing details, mark status as False to retry
|
| 363 |
-
if symbol in mc_links:
|
| 364 |
-
mc_links[symbol]["MC_STATUS"] = False
|
| 365 |
-
continue
|
| 366 |
-
|
| 367 |
-
else:
|
| 368 |
-
# Stock already has a link, just scrape & update the metrics
|
| 369 |
-
logging.info(f" Scraping metrics from existing link: {mc_link}")
|
| 370 |
-
time.sleep(args.delay + random.uniform(0.1, 1.0))
|
| 371 |
-
details = scrape_stock_details(mc_link)
|
| 372 |
-
scraped_count += 1
|
| 373 |
-
|
| 374 |
-
if details:
|
| 375 |
-
# Verified check
|
| 376 |
-
if details["NSE_SYMBOL"] and details["NSE_SYMBOL"] != symbol:
|
| 377 |
-
logging.warning(f" --> Warning: URL NSE symbol mismatch. Expected {symbol}, got {details['NSE_SYMBOL']}")
|
| 378 |
-
|
| 379 |
-
mc_links[symbol] = {
|
| 380 |
-
"MC_LINK": mc_link,
|
| 381 |
-
"MC_COMPANY_CODE": mc_code,
|
| 382 |
-
"MC_COMPANY_NAME": mc_name,
|
| 383 |
-
"MC_SCORE": details["MC_SCORE"],
|
| 384 |
-
"MC_MCAP": details["MC_MCAP"],
|
| 385 |
-
"MC_FV": details["MC_FV"],
|
| 386 |
-
"MC_ATH": details["MC_ATH"],
|
| 387 |
-
"MC_ATL": details["MC_ATL"],
|
| 388 |
-
"MC_STATUS": True
|
| 389 |
-
}
|
| 390 |
-
matched_count += 1
|
| 391 |
-
else:
|
| 392 |
-
logging.warning(f" Failed to update metrics for: {symbol}")
|
| 393 |
-
mc_links[symbol]["MC_STATUS"] = False
|
| 394 |
-
|
| 395 |
-
# Periodic save (every 10 updates)
|
| 396 |
-
if matched_count % 10 == 0:
|
| 397 |
-
with open(paths.mc_link_file, 'w') as f:
|
| 398 |
-
json.dump(mc_links, f, indent=4, sort_keys=True)
|
| 399 |
-
logging.info("Periodic progress saved.")
|
| 400 |
-
|
| 401 |
-
except KeyboardInterrupt:
|
| 402 |
-
logging.info("\n--- PAUSED BY USER ---")
|
| 403 |
-
logging.info("Saving progress before exit...")
|
| 404 |
-
|
| 405 |
-
finally:
|
| 406 |
-
# Final save
|
| 407 |
-
with open(paths.mc_link_file, 'w') as f:
|
| 408 |
-
json.dump(mc_links, f, indent=4, sort_keys=True)
|
| 409 |
-
|
| 410 |
-
elapsed = time.time() - start_time
|
| 411 |
-
logging.info(f"\nExecution finished.")
|
| 412 |
-
logging.info(f" - Elapsed Time: {elapsed:.2f} seconds")
|
| 413 |
-
logging.info(f" - Requests Executed: {scraped_count}")
|
| 414 |
-
logging.info(f" - Stocks Successfully Updated: {matched_count}")
|
| 415 |
-
logging.info(f" - Total entries now in json: {len(mc_links)}")
|
| 416 |
-
|
| 417 |
-
if __name__ == "__main__":
|
| 418 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nseprocessor.py
DELETED
|
@@ -1,1258 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
NSE Bhavcopy Data Processor
|
| 3 |
-
|
| 4 |
-
This script processes NSE bhavcopy files with proper DataFrame handling and enhanced logging.
|
| 5 |
-
Supports both fresh processing and incremental updates.
|
| 6 |
-
|
| 7 |
-
Features:
|
| 8 |
-
- Date-ordered processing
|
| 9 |
-
- Proper DataFrame operations
|
| 10 |
-
- Enhanced logging
|
| 11 |
-
- Progress tracking
|
| 12 |
-
- Data validation
|
| 13 |
-
- Optional corporate action application for full processing runs
|
| 14 |
-
|
| 15 |
-
Author: Your Name
|
| 16 |
-
Date: June 2025 (Updated)
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
import pandas as pd
|
| 20 |
-
import numpy as np
|
| 21 |
-
import zipfile
|
| 22 |
-
import re
|
| 23 |
-
import logging
|
| 24 |
-
from pathlib import Path
|
| 25 |
-
from datetime import datetime, timedelta
|
| 26 |
-
import time
|
| 27 |
-
from pathlib import Path
|
| 28 |
-
import logging
|
| 29 |
-
from tqdm import tqdm
|
| 30 |
-
import sys
|
| 31 |
-
import gc
|
| 32 |
-
import json
|
| 33 |
-
try:
|
| 34 |
-
import talib
|
| 35 |
-
except ImportError:
|
| 36 |
-
talib = None
|
| 37 |
-
try:
|
| 38 |
-
import pandas_ta_classic as ta
|
| 39 |
-
except ImportError:
|
| 40 |
-
try:
|
| 41 |
-
import pandas_ta as ta
|
| 42 |
-
except ImportError:
|
| 43 |
-
ta = None
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
import fastparquet
|
| 47 |
-
import warnings
|
| 48 |
-
from sklearn.preprocessing import MinMaxScaler
|
| 49 |
-
from sklearn.feature_selection import mutual_info_regression
|
| 50 |
-
|
| 51 |
-
warnings.filterwarnings('ignore')
|
| 52 |
-
warnings.simplefilter('ignore', FutureWarning)
|
| 53 |
-
|
| 54 |
-
def _calculate_indicators_for_group(group_data_tuple):
|
| 55 |
-
symbol, group, col_mapping = group_data_tuple
|
| 56 |
-
df = group.copy()
|
| 57 |
-
|
| 58 |
-
# Rename columns to match pandas_ta expectations
|
| 59 |
-
df = df.rename(columns=col_mapping)
|
| 60 |
-
|
| 61 |
-
# ===== MOMENTUM INDICATORS (14) =====
|
| 62 |
-
df.ta.rsi(length=14, append=True)
|
| 63 |
-
df.ta.macd(fast=12, slow=26, signal=9, append=True)
|
| 64 |
-
stoch = df.ta.stoch(length=14, k=3, d=3, append=True)
|
| 65 |
-
df.ta.willr(length=14, append=True)
|
| 66 |
-
df.ta.cmo(length=14, append=True)
|
| 67 |
-
df.ta.cci(length=20, append=True)
|
| 68 |
-
df.ta.roc(length=12, append=True)
|
| 69 |
-
df.ta.mfi(length=14, append=True)
|
| 70 |
-
df.ta.fisher(length=9, append=True)
|
| 71 |
-
df.ta.kst(append=True)
|
| 72 |
-
df.ta.mom(length=10, append=True)
|
| 73 |
-
df.ta.tsi(long=25, short=13, append=True)
|
| 74 |
-
df.ta.uo(fast=7, medium=14, slow=28, append=True)
|
| 75 |
-
df.ta.ao(append=True)
|
| 76 |
-
|
| 77 |
-
# ===== TREND INDICATORS (18) =====
|
| 78 |
-
df.ta.sma(length=20, append=True)
|
| 79 |
-
df.ta.sma(length=50, append=True)
|
| 80 |
-
df.ta.sma(length=200, append=True)
|
| 81 |
-
df.ta.ema(length=12, append=True)
|
| 82 |
-
df.ta.ema(length=26, append=True)
|
| 83 |
-
df.ta.ema(length=50, append=True)
|
| 84 |
-
df.ta.ema(length=200, append=True)
|
| 85 |
-
df.ta.adx(length=14, append=True)
|
| 86 |
-
df.ta.aroon(length=14, append=True)
|
| 87 |
-
df.ta.supertrend(length=10, multiplier=3, append=True)
|
| 88 |
-
df.ta.psar(af0=0.02, af=0.2, append=True)
|
| 89 |
-
df.ta.alma(length=9, append=True)
|
| 90 |
-
df.ta.dema(length=10, append=True)
|
| 91 |
-
df.ta.tema(length=10, append=True)
|
| 92 |
-
df.ta.hma(length=9, append=True)
|
| 93 |
-
df.ta.kama(length=10, append=True)
|
| 94 |
-
df.ta.linreg(length=25, append=True)
|
| 95 |
-
|
| 96 |
-
# ===== VOLATILITY INDICATORS (14) =====
|
| 97 |
-
df['True_Range'] = np.maximum(
|
| 98 |
-
np.maximum(
|
| 99 |
-
df['High'] - df['Low'],
|
| 100 |
-
np.abs(df['High'] - df['Close'].shift())
|
| 101 |
-
),
|
| 102 |
-
np.abs(df['Low'] - df['Close'].shift())
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
-
bbands = df.ta.bbands(length=20, std=2, append=True)
|
| 106 |
-
df.ta.atr(length=14, append=True)
|
| 107 |
-
df.ta.natr(length=14, append=True)
|
| 108 |
-
df.ta.kc(length=20, scalar=2, append=True)
|
| 109 |
-
df.ta.stdev(length=20, append=True)
|
| 110 |
-
|
| 111 |
-
df['Historical_Volatility'] = df['Close'].pct_change().rolling(20).std() * np.sqrt(252)
|
| 112 |
-
df['Donchian_High'] = df['High'].rolling(20).max()
|
| 113 |
-
df['Donchian_Low'] = df['Low'].rolling(20).min()
|
| 114 |
-
df['Donchian_Mid'] = (df['Donchian_High'] + df['Donchian_Low']) / 2
|
| 115 |
-
|
| 116 |
-
hl_range = df['High'] - df['Low']
|
| 117 |
-
mass_index = (hl_range.ewm(span=9).mean() / hl_range.ewm(span=9).mean().ewm(span=9).mean()).rolling(25).sum()
|
| 118 |
-
df['Mass_Index'] = mass_index
|
| 119 |
-
|
| 120 |
-
df['RVI'] = df['Close'].rolling(14).std() / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 121 |
-
|
| 122 |
-
atr_sum = df['True_Range'].rolling(14).sum()
|
| 123 |
-
high_low = np.log10(df['High'].rolling(14).max() / (df['Low'].rolling(14).min() + 1e-9))
|
| 124 |
-
df['CHOP'] = 100 * high_low / np.log10(14)
|
| 125 |
-
|
| 126 |
-
# ===== VOLUME INDICATORS (13) =====
|
| 127 |
-
df.ta.obv(append=True)
|
| 128 |
-
df.ta.ad(append=True)
|
| 129 |
-
df.ta.adosc(fast=3, slow=10, append=True)
|
| 130 |
-
df.ta.cmf(length=20, append=True)
|
| 131 |
-
|
| 132 |
-
df['VPT'] = (df['Close'].pct_change() * df['Volume']).cumsum()
|
| 133 |
-
df['Volume_SMA_20'] = df['Volume'].rolling(20).mean()
|
| 134 |
-
|
| 135 |
-
klinger_fast = df['Volume'].ewm(span=34).mean()
|
| 136 |
-
klinger_slow = df['Volume'].ewm(span=55).mean()
|
| 137 |
-
df['KO'] = klinger_fast - klinger_slow
|
| 138 |
-
|
| 139 |
-
distance_moved = (df['High'] + df['Low']) / 2 - (df['High'].shift(1) + df['Low'].shift(1)) / 2
|
| 140 |
-
box_height = df['High'] - df['Low']
|
| 141 |
-
eom = distance_moved / (box_height / df['Volume'])
|
| 142 |
-
df['EOM'] = eom.rolling(14).mean()
|
| 143 |
-
|
| 144 |
-
df['EFI'] = (df['Close'] - df['Close'].shift(1)) * df['Volume']
|
| 145 |
-
df['EFI'] = df['EFI'].ewm(span=13).mean()
|
| 146 |
-
|
| 147 |
-
tr = np.sqrt((df['High'] - df['Low'])**2 + (df['High'] - df['Close'].shift(1))**2 + (df['Low'] - df['Close'].shift(1))**2)
|
| 148 |
-
vm_plus = np.abs(df['High'] - df['Low'].shift(1))
|
| 149 |
-
vm_minus = np.abs(df['Low'] - df['High'].shift(1))
|
| 150 |
-
df['VI_Plus'] = vm_plus.rolling(14).sum() / tr.rolling(14).sum()
|
| 151 |
-
df['VI_Minus'] = vm_minus.rolling(14).sum() / tr.rolling(14).sum()
|
| 152 |
-
|
| 153 |
-
# ===== OSCILLATORS & ADDITIONAL =====
|
| 154 |
-
df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 155 |
-
df['Median_Price'] = (df['High'] + df['Low']) / 2
|
| 156 |
-
df['HL2'] = (df['High'] + df['Low']) / 2
|
| 157 |
-
df['OC2'] = (df['Open'] + df['Close']) / 2
|
| 158 |
-
df['HLC3'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 159 |
-
df['OHLC4'] = (df['Open'] + df['High'] + df['Low'] + df['Close']) / 4
|
| 160 |
-
|
| 161 |
-
df['Pivot'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 162 |
-
df['R1'] = 2 * df['Pivot'] - df['Low']
|
| 163 |
-
df['S1'] = 2 * df['Pivot'] - df['High']
|
| 164 |
-
df['R2'] = df['Pivot'] + (df['High'] - df['Low'])
|
| 165 |
-
df['S2'] = df['Pivot'] - (df['High'] - df['Low'])
|
| 166 |
-
|
| 167 |
-
weights = np.arange(1, 21)
|
| 168 |
-
df['WMA'] = df['Close'].rolling(20).apply(lambda x: np.sum(x * weights) / np.sum(weights))
|
| 169 |
-
df['RMA'] = df['Close'].ewm(span=20, adjust=False).mean()
|
| 170 |
-
df['VWMA'] = (df['Close'] * df['Volume']).rolling(20).sum() / df['Volume'].rolling(20).sum()
|
| 171 |
-
|
| 172 |
-
df['Price_Position'] = (df['Close'] - df['Low'].rolling(14).min()) / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 173 |
-
df['Oscillator_Ratio'] = (df['Close'] - df['SMA_20']) / df['STDEV_20']
|
| 174 |
-
|
| 175 |
-
df['Price_vs_SMA20'] = df['Close'] - df['SMA_20']
|
| 176 |
-
df['Price_vs_EMA12'] = df['Close'] - df['EMA_12']
|
| 177 |
-
df['EMA12_vs_EMA26'] = df['EMA_12'] - df['EMA_26']
|
| 178 |
-
df['SMA50_vs_SMA200'] = df['SMA_50'] - df['SMA_200']
|
| 179 |
-
|
| 180 |
-
df['RSI_Divergence'] = df['RSI_14'].diff()
|
| 181 |
-
df['Volume_Divergence'] = df['Volume'].diff()
|
| 182 |
-
|
| 183 |
-
df['Momentum_Strength'] = df['RSI_14'].rolling(5).std()
|
| 184 |
-
df['Trend_Strength'] = df['ADX_14']
|
| 185 |
-
|
| 186 |
-
df['Close_Change'] = df['Close'].pct_change()
|
| 187 |
-
df['High_Low_Ratio'] = (df['High'] - df['Low']) / df['Close']
|
| 188 |
-
df['Close_Open_Pct'] = (df['Close'] - df['Open']) / df['Open']
|
| 189 |
-
df['Volume_MA_Ratio'] = df['Volume'] / df['Volume'].rolling(20).mean()
|
| 190 |
-
|
| 191 |
-
# Revert column names to uppercase
|
| 192 |
-
inv_col_mapping = {v: k for k, v in col_mapping.items()}
|
| 193 |
-
df = df.rename(columns=inv_col_mapping)
|
| 194 |
-
|
| 195 |
-
# Add sentiment & news features (4 features)
|
| 196 |
-
df['Sentiment'] = 0.0
|
| 197 |
-
df['News_Count'] = 0.0
|
| 198 |
-
df['News_Sentiment'] = 0.0
|
| 199 |
-
df['News_Impact'] = 0.0
|
| 200 |
-
|
| 201 |
-
# Add hybrid strategy indicators (4 features)
|
| 202 |
-
df['VIX'] = 15.0
|
| 203 |
-
df['Vol_20MA'] = df['NET_TRDQTY'].rolling(window=20).mean().bfill().fillna(0.0)
|
| 204 |
-
df['Prev_High'] = df['HIGH_PRICE'].shift(1).bfill().fillna(0.0)
|
| 205 |
-
df['Breakout_Signal'] = ((df['CLOSE_PRICE'] > df['Prev_High']) & (df['NET_TRDQTY'] > df['Vol_20MA'])).astype(float)
|
| 206 |
-
|
| 207 |
-
return df
|
| 208 |
-
|
| 209 |
-
class NSEDataProcessor:
|
| 210 |
-
"""Process NSE bhavcopy data with optimized display and relative paths"""
|
| 211 |
-
|
| 212 |
-
def __init__(self):
|
| 213 |
-
self.setup_paths()
|
| 214 |
-
self.setup_logging()
|
| 215 |
-
self.initialize_metrics()
|
| 216 |
-
|
| 217 |
-
# Define schema (existing schema code)
|
| 218 |
-
self.schema = {
|
| 219 |
-
'SYMBOL': 'category',
|
| 220 |
-
'SECURITY': 'category',
|
| 221 |
-
'SERIES': 'category',
|
| 222 |
-
'TRAN_DATE': 'datetime64[ns]',
|
| 223 |
-
'OPEN_PRICE': 'float32',
|
| 224 |
-
'HIGH_PRICE': 'float32',
|
| 225 |
-
'LOW_PRICE': 'float32',
|
| 226 |
-
'CLOSE_PRICE': 'float32',
|
| 227 |
-
'PREV_CL_PR': 'float32',
|
| 228 |
-
'NET_TRDVAL': 'float64',
|
| 229 |
-
'NET_TRDQTY': 'int64',
|
| 230 |
-
'TRADES': 'int64',
|
| 231 |
-
'HI_52_WK': 'float32',
|
| 232 |
-
'LO_52_WK': 'float32',
|
| 233 |
-
'HI_DIFF': 'float32',
|
| 234 |
-
'LO_DIFF': 'float32',
|
| 235 |
-
'DAILY_RETURN': 'float32',
|
| 236 |
-
'VOLATILITY': 'float32'
|
| 237 |
-
# Additional feature engineering columns, added runtime and saved to extract file
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
self.string_cols = ['MKT', 'SERIES', 'SYMBOL', 'SECURITY']
|
| 241 |
-
self.numeric_cols = [
|
| 242 |
-
'PREV_CL_PR', 'OPEN_PRICE', 'HIGH_PRICE', 'LOW_PRICE',
|
| 243 |
-
'CLOSE_PRICE', 'NET_TRDVAL', 'NET_TRDQTY', 'TRADES',
|
| 244 |
-
'HI_52_WK', 'LO_52_WK'
|
| 245 |
-
]
|
| 246 |
-
self.corporate_actions = [] # Initialize corporate actions list
|
| 247 |
-
|
| 248 |
-
def setup_paths(self):
|
| 249 |
-
"""Setup using relative paths"""
|
| 250 |
-
try:
|
| 251 |
-
self.base_dir = Path.cwd()
|
| 252 |
-
self.downloads_dir = self.base_dir / 'downloads'
|
| 253 |
-
self.processed_dir = self.base_dir / 'processed'
|
| 254 |
-
self.corpact_file = self.processed_dir / 'nse_dashboard_corpact.json'
|
| 255 |
-
self.extract_file = self.processed_dir / 'nse_extract_data.parquet'
|
| 256 |
-
self.parquet_path = self.processed_dir / 'nse_processed_data.parquet'
|
| 257 |
-
|
| 258 |
-
if not self.downloads_dir.exists():
|
| 259 |
-
raise FileNotFoundError(f"Downloads directory not found: {self.downloads_dir}")
|
| 260 |
-
|
| 261 |
-
self.processed_dir.mkdir(parents=True, exist_ok=True)
|
| 262 |
-
|
| 263 |
-
except Exception as e:
|
| 264 |
-
print(f"Path setup failed: {str(e)}")
|
| 265 |
-
sys.exit(1)
|
| 266 |
-
|
| 267 |
-
def setup_logging(self):
|
| 268 |
-
"""Configure minimal logging"""
|
| 269 |
-
try:
|
| 270 |
-
log_format = '%(asctime)s - %(levelname)s - %(message)s'
|
| 271 |
-
logging.basicConfig(
|
| 272 |
-
filename=self.downloads_dir / 'bulk_data_processing.log',
|
| 273 |
-
level=logging.INFO,
|
| 274 |
-
format=log_format
|
| 275 |
-
)
|
| 276 |
-
self.logger = logging.getLogger(__name__)
|
| 277 |
-
|
| 278 |
-
except Exception as e:
|
| 279 |
-
print(f"Logging setup failed: {str(e)}")
|
| 280 |
-
sys.exit(1)
|
| 281 |
-
|
| 282 |
-
def initialize_metrics(self):
|
| 283 |
-
"""Initialize processing metrics"""
|
| 284 |
-
self.metrics = {
|
| 285 |
-
'start_time': None,
|
| 286 |
-
'files_found': 0,
|
| 287 |
-
'files_processed': 0,
|
| 288 |
-
'files_failed': 0,
|
| 289 |
-
'rows_processed': 0,
|
| 290 |
-
'processing_errors': [],
|
| 291 |
-
'processed_files': [],
|
| 292 |
-
'failed_files': [],
|
| 293 |
-
'start_datetime': None,
|
| 294 |
-
'end_datetime': None,
|
| 295 |
-
'duration': None,
|
| 296 |
-
'batch_metrics': [],
|
| 297 |
-
'corporate_actions_applied_count': 0 # New metric for corporate actions
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
def get_latest_processed_date(self):
|
| 301 |
-
"""Get latest processed date from existing output"""
|
| 302 |
-
try:
|
| 303 |
-
if self.parquet_path.exists():
|
| 304 |
-
df = pd.read_parquet(self.parquet_path, columns=['TRAN_DATE'])
|
| 305 |
-
latest_date = pd.to_datetime(df['TRAN_DATE']).max()
|
| 306 |
-
self.logger.info(f"Latest processed date: {latest_date.strftime('%Y-%m-%d')}")
|
| 307 |
-
print(f"Found existing data - processing files newer than {latest_date.strftime('%Y-%m-%d')}")
|
| 308 |
-
return latest_date
|
| 309 |
-
else:
|
| 310 |
-
self.logger.info("No existing data found - performing fresh processing")
|
| 311 |
-
print("Starting fresh processing - no existing data found")
|
| 312 |
-
return None
|
| 313 |
-
except Exception as e:
|
| 314 |
-
self.logger.error(f"Failed to get latest date: {str(e)}")
|
| 315 |
-
return None
|
| 316 |
-
|
| 317 |
-
def extract_date_from_filename(self, filename):
|
| 318 |
-
"""Extract and validate date from filename (supports PdDDMMYY, PRDDMMYY, pdDDMMYYYY)"""
|
| 319 |
-
try:
|
| 320 |
-
name = Path(filename).stem
|
| 321 |
-
|
| 322 |
-
# Match old format: Pd or PR + ddmmyy
|
| 323 |
-
match_old = re.search(r'(?i)(Pd|PR)(\d{2})(\d{2})(\d{2})', name)
|
| 324 |
-
|
| 325 |
-
# Match new format: pd + ddmmyyyy
|
| 326 |
-
match_new = re.search(r'(?i)pd(\d{2})(\d{2})(\d{4})', name)
|
| 327 |
-
|
| 328 |
-
if match_new:
|
| 329 |
-
day, month, year = match_new.groups()
|
| 330 |
-
elif match_old:
|
| 331 |
-
day, month, year = match_old.groups()[1:] # skip prefix
|
| 332 |
-
year = str(2000 + int(year)) # convert yy to yyyy
|
| 333 |
-
else:
|
| 334 |
-
return None
|
| 335 |
-
|
| 336 |
-
date_obj = datetime(int(year), int(month), int(day))
|
| 337 |
-
|
| 338 |
-
if date_obj.year < 2000 or date_obj > datetime.now():
|
| 339 |
-
raise ValueError(f"Date out of valid range: {date_obj}")
|
| 340 |
-
|
| 341 |
-
return date_obj.strftime('%Y-%m-%d')
|
| 342 |
-
|
| 343 |
-
except Exception as e:
|
| 344 |
-
self.logger.error(f"Date extraction failed for {filename}: {str(e)}")
|
| 345 |
-
return None
|
| 346 |
-
|
| 347 |
-
def process_zip_file(self, zip_path):
|
| 348 |
-
"""Process ZIP file with error handling"""
|
| 349 |
-
try:
|
| 350 |
-
with zipfile.ZipFile(zip_path) as zf:
|
| 351 |
-
csv_files = [f for f in zf.namelist()
|
| 352 |
-
if re.match(r'^pd\d{6}(\d{2})?\.csv$', Path(f).name, re.IGNORECASE)]
|
| 353 |
-
|
| 354 |
-
if not csv_files:
|
| 355 |
-
self.logger.warning(f"No matching CSV in {zip_path}")
|
| 356 |
-
return None
|
| 357 |
-
|
| 358 |
-
csv_file = csv_files[0]
|
| 359 |
-
tran_date = self.extract_date_from_filename(Path(csv_file).name)
|
| 360 |
-
if not tran_date:
|
| 361 |
-
return None
|
| 362 |
-
|
| 363 |
-
with zf.open(csv_file) as f:
|
| 364 |
-
df = pd.read_csv(f, thousands=',')
|
| 365 |
-
return self.process_dataframe(df, tran_date)
|
| 366 |
-
|
| 367 |
-
except Exception as e:
|
| 368 |
-
self.logger.error(f"ZIP processing failed - {zip_path}: {str(e)}")
|
| 369 |
-
self.metrics['files_failed'] += 1
|
| 370 |
-
return None
|
| 371 |
-
|
| 372 |
-
def process_dataframe(self, df, tran_date):
|
| 373 |
-
"""Process DataFrame with proper indexing"""
|
| 374 |
-
try:
|
| 375 |
-
# Create explicit copy
|
| 376 |
-
df = df.copy()
|
| 377 |
-
|
| 378 |
-
# Clean string columns
|
| 379 |
-
for col in self.string_cols:
|
| 380 |
-
if col in df.columns:
|
| 381 |
-
df.loc[:, col] = df[col].astype(str).str.strip()
|
| 382 |
-
|
| 383 |
-
# Filter rows and create new copy
|
| 384 |
-
mask = (
|
| 385 |
-
(df['SERIES'].str.upper() == 'EQ') &
|
| 386 |
-
df['MKT'].notna()
|
| 387 |
-
)
|
| 388 |
-
mask_index = (
|
| 389 |
-
df['MKT'].str.upper() == 'Y'
|
| 390 |
-
)
|
| 391 |
-
df_index = df[mask_index].copy()
|
| 392 |
-
df = df[mask].copy()
|
| 393 |
-
|
| 394 |
-
# Create SYMBOL column for df_index
|
| 395 |
-
if not df_index.empty:
|
| 396 |
-
df_index.loc[:, 'SYMBOL'] = df_index['SECURITY'].str.upper().str.replace(r'\s+','', regex=True)
|
| 397 |
-
df_index = df_index[df_index['SYMBOL'].notna()]
|
| 398 |
-
df_index = df_index.drop_duplicates(subset=['SYMBOL'])
|
| 399 |
-
|
| 400 |
-
# Concat df_index to df
|
| 401 |
-
df = pd.concat([df, df_index], ignore_index=True)
|
| 402 |
-
|
| 403 |
-
# Add transaction date
|
| 404 |
-
df.loc[:, 'TRAN_DATE'] = pd.to_datetime(tran_date)
|
| 405 |
-
|
| 406 |
-
# Process numeric columns
|
| 407 |
-
for col in self.numeric_cols:
|
| 408 |
-
if col in df.columns:
|
| 409 |
-
df.loc[:, col] = pd.to_numeric(
|
| 410 |
-
df[col].astype(str).str.replace(',', ''),
|
| 411 |
-
errors='coerce'
|
| 412 |
-
)
|
| 413 |
-
|
| 414 |
-
# Drop rows with missing numerics
|
| 415 |
-
df = df.dropna(subset=self.numeric_cols)
|
| 416 |
-
|
| 417 |
-
if df.empty:
|
| 418 |
-
return None
|
| 419 |
-
|
| 420 |
-
# Calculate indicators and validate
|
| 421 |
-
df = self.add_technical_indicators(df)
|
| 422 |
-
df = self.validate_schema(df)
|
| 423 |
-
|
| 424 |
-
if df is not None:
|
| 425 |
-
self.metrics['rows_processed'] += len(df)
|
| 426 |
-
|
| 427 |
-
return df
|
| 428 |
-
|
| 429 |
-
except Exception as e:
|
| 430 |
-
self.logger.error(f"DataFrame processing error: {str(e)}")
|
| 431 |
-
return None
|
| 432 |
-
|
| 433 |
-
def add_technical_indicators(self, df):
|
| 434 |
-
"""Calculate technical indicators with proper dtype handling"""
|
| 435 |
-
try:
|
| 436 |
-
df = df.copy()
|
| 437 |
-
|
| 438 |
-
# Initialize indicator columns with np.nan
|
| 439 |
-
indicators = ['HI_DIFF', 'LO_DIFF', 'DAILY_RETURN', 'VOLATILITY']
|
| 440 |
-
for col in indicators:
|
| 441 |
-
df[col] = np.nan
|
| 442 |
-
|
| 443 |
-
# Helper function for safe calculation
|
| 444 |
-
def calculate_pct_change(a, b, divisor):
|
| 445 |
-
"""Calculate percentage change with proper dtype handling"""
|
| 446 |
-
result = (
|
| 447 |
-
((a.astype('float32') - b.astype('float32')) /
|
| 448 |
-
divisor.astype('float32') * 100)
|
| 449 |
-
.round(2)
|
| 450 |
-
.astype('float32')
|
| 451 |
-
)
|
| 452 |
-
return result
|
| 453 |
-
|
| 454 |
-
# Calculate HI_DIFF
|
| 455 |
-
mask = df['HI_52_WK'] > 0
|
| 456 |
-
if mask.any():
|
| 457 |
-
df.loc[mask, 'HI_DIFF'] = calculate_pct_change(
|
| 458 |
-
df.loc[mask, 'HI_52_WK'],
|
| 459 |
-
df.loc[mask, 'CLOSE_PRICE'],
|
| 460 |
-
df.loc[mask, 'HI_52_WK']
|
| 461 |
-
)
|
| 462 |
-
|
| 463 |
-
# Calculate LO_DIFF
|
| 464 |
-
mask = df['LO_52_WK'] > 0
|
| 465 |
-
if mask.any():
|
| 466 |
-
df.loc[mask, 'LO_DIFF'] = calculate_pct_change(
|
| 467 |
-
df.loc[mask, 'CLOSE_PRICE'],
|
| 468 |
-
df.loc[mask, 'LO_52_WK'],
|
| 469 |
-
df.loc[mask, 'LO_52_WK']
|
| 470 |
-
)
|
| 471 |
-
|
| 472 |
-
# Calculate DAILY_RETURN
|
| 473 |
-
mask = df['PREV_CL_PR'] > 0
|
| 474 |
-
if mask.any():
|
| 475 |
-
df.loc[mask, 'DAILY_RETURN'] = calculate_pct_change(
|
| 476 |
-
df.loc[mask, 'CLOSE_PRICE'],
|
| 477 |
-
df.loc[mask, 'PREV_CL_PR'],
|
| 478 |
-
df.loc[mask, 'PREV_CL_PR']
|
| 479 |
-
)
|
| 480 |
-
|
| 481 |
-
# Calculate VOLATILITY
|
| 482 |
-
mask = df['OPEN_PRICE'] > 0
|
| 483 |
-
if mask.any():
|
| 484 |
-
high_low_diff = (df.loc[mask, 'HIGH_PRICE'] - df.loc[mask, 'LOW_PRICE'])
|
| 485 |
-
df.loc[mask, 'VOLATILITY'] = calculate_pct_change(
|
| 486 |
-
high_low_diff,
|
| 487 |
-
pd.Series(0, index=high_low_diff.index),
|
| 488 |
-
df.loc[mask, 'OPEN_PRICE']
|
| 489 |
-
)
|
| 490 |
-
|
| 491 |
-
# Drop rows where any indicator is missing
|
| 492 |
-
return df.dropna(subset=indicators)
|
| 493 |
-
|
| 494 |
-
except Exception as e:
|
| 495 |
-
self.logger.error(f"Error calculating indicators: {str(e)}")
|
| 496 |
-
return df
|
| 497 |
-
|
| 498 |
-
def _create_forward_target(self, group, lookahead_days=1):
|
| 499 |
-
"""
|
| 500 |
-
Creates a forward-looking regression target: the next closing price after `lookahead_days`.
|
| 501 |
-
Optionally allows for volatility computation or future annotation.
|
| 502 |
-
Returns a DataFrame with 'TARGET_PRICES' column containing single future prices.
|
| 503 |
-
"""
|
| 504 |
-
# Ensure chronological order
|
| 505 |
-
group = group.sort_values(by='TRAN_DATE').copy()
|
| 506 |
-
|
| 507 |
-
# Extract future single-step targets
|
| 508 |
-
close_prices = group['CLOSE_PRICE'].values
|
| 509 |
-
future_targets = []
|
| 510 |
-
|
| 511 |
-
for i in range(len(close_prices)):
|
| 512 |
-
if i + lookahead_days < len(close_prices):
|
| 513 |
-
future_targets.append(close_prices[i + lookahead_days])
|
| 514 |
-
else:
|
| 515 |
-
future_targets.append(np.nan)
|
| 516 |
-
|
| 517 |
-
group['TARGET_PRICES'] = future_targets
|
| 518 |
-
|
| 519 |
-
# Drop rows where TARGET_PRICES is NaN
|
| 520 |
-
group = group[~pd.isna(group['TARGET_PRICES'])].reset_index(drop=True)
|
| 521 |
-
|
| 522 |
-
return group
|
| 523 |
-
|
| 524 |
-
def _create_simple_target(self, group, lookahead_days=1, method="single"):
|
| 525 |
-
"""
|
| 526 |
-
Generates regression targets for each row based on future closing prices.
|
| 527 |
-
|
| 528 |
-
Parameters:
|
| 529 |
-
- group: DataFrame containing at least 'TRAN_DATE' and 'CLOSE_PRICE'
|
| 530 |
-
- lookahead_days: Number of days to look ahead for target generation
|
| 531 |
-
- method: 'single' for next-day price, 'mean' for average of next N days
|
| 532 |
-
|
| 533 |
-
Returns:
|
| 534 |
-
- Modified DataFrame with a new column 'TARGET_PRICES'
|
| 535 |
-
"""
|
| 536 |
-
# Ensure chronological order
|
| 537 |
-
group = group.sort_values(by='TRAN_DATE').copy()
|
| 538 |
-
|
| 539 |
-
# Extract close prices
|
| 540 |
-
close_prices = group['CLOSE_PRICE'].values
|
| 541 |
-
|
| 542 |
-
# Generate targets using vectorized slicing
|
| 543 |
-
if method == "single":
|
| 544 |
-
target = np.roll(close_prices, -lookahead_days)
|
| 545 |
-
elif method == "mean":
|
| 546 |
-
target = np.array([
|
| 547 |
-
np.mean(close_prices[i+1:i+1+lookahead_days])
|
| 548 |
-
if i + lookahead_days < len(close_prices) else np.nan
|
| 549 |
-
for i in range(len(close_prices))
|
| 550 |
-
])
|
| 551 |
-
else:
|
| 552 |
-
raise ValueError(f"Unsupported method: {method}")
|
| 553 |
-
|
| 554 |
-
# Assign and clean
|
| 555 |
-
group['TARGET_PRICES'] = target
|
| 556 |
-
group = group[~pd.isna(group['TARGET_PRICES'])].reset_index(drop=True)
|
| 557 |
-
|
| 558 |
-
return group
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
def calculate_mlbench_indicators(self, df):
|
| 562 |
-
df = df.copy()
|
| 563 |
-
|
| 564 |
-
# ===== MOMENTUM INDICATORS (14) =====
|
| 565 |
-
df.ta.rsi(length=14, append=True)
|
| 566 |
-
df.ta.macd(fast=12, slow=26, signal=9, append=True)
|
| 567 |
-
stoch = df.ta.stoch(length=14, k=3, d=3, append=True)
|
| 568 |
-
df.ta.willr(length=14, append=True)
|
| 569 |
-
df.ta.cmo(length=14, append=True)
|
| 570 |
-
df.ta.cci(length=20, append=True)
|
| 571 |
-
df.ta.roc(length=12, append=True)
|
| 572 |
-
df.ta.mfi(length=14, append=True)
|
| 573 |
-
df.ta.fisher(length=9, append=True)
|
| 574 |
-
df.ta.kst(append=True)
|
| 575 |
-
df.ta.mom(length=10, append=True)
|
| 576 |
-
df.ta.tsi(long=25, short=13, append=True)
|
| 577 |
-
df.ta.uo(fast=7, medium=14, slow=28, append=True)
|
| 578 |
-
df.ta.ao(append=True)
|
| 579 |
-
|
| 580 |
-
# ===== TREND INDICATORS (18) =====
|
| 581 |
-
df.ta.sma(length=20, append=True)
|
| 582 |
-
df.ta.sma(length=50, append=True)
|
| 583 |
-
df.ta.sma(length=200, append=True)
|
| 584 |
-
df.ta.ema(length=12, append=True)
|
| 585 |
-
df.ta.ema(length=26, append=True)
|
| 586 |
-
df.ta.ema(length=50, append=True)
|
| 587 |
-
df.ta.ema(length=200, append=True)
|
| 588 |
-
df.ta.adx(length=14, append=True)
|
| 589 |
-
df.ta.aroon(length=14, append=True)
|
| 590 |
-
df.ta.supertrend(length=10, multiplier=3, append=True)
|
| 591 |
-
df.ta.psar(af0=0.02, af=0.2, append=True)
|
| 592 |
-
df.ta.alma(length=9, append=True)
|
| 593 |
-
df.ta.dema(length=10, append=True)
|
| 594 |
-
df.ta.tema(length=10, append=True)
|
| 595 |
-
df.ta.hma(length=9, append=True)
|
| 596 |
-
df.ta.kama(length=10, append=True)
|
| 597 |
-
df.ta.linreg(length=25, append=True)
|
| 598 |
-
|
| 599 |
-
# ===== VOLATILITY INDICATORS (14) =====
|
| 600 |
-
df['True_Range'] = np.maximum(
|
| 601 |
-
np.maximum(
|
| 602 |
-
df['High'] - df['Low'],
|
| 603 |
-
np.abs(df['High'] - df['Close'].shift())
|
| 604 |
-
),
|
| 605 |
-
np.abs(df['Low'] - df['Close'].shift())
|
| 606 |
-
)
|
| 607 |
-
|
| 608 |
-
bbands = df.ta.bbands(length=20, std=2, append=True)
|
| 609 |
-
df.ta.atr(length=14, append=True)
|
| 610 |
-
df.ta.natr(length=14, append=True)
|
| 611 |
-
df.ta.kc(length=20, scalar=2, append=True)
|
| 612 |
-
df.ta.stdev(length=20, append=True)
|
| 613 |
-
|
| 614 |
-
df['Historical_Volatility'] = df['Close'].pct_change().rolling(20).std() * np.sqrt(252)
|
| 615 |
-
df['Donchian_High'] = df['High'].rolling(20).max()
|
| 616 |
-
df['Donchian_Low'] = df['Low'].rolling(20).min()
|
| 617 |
-
df['Donchian_Mid'] = (df['Donchian_High'] + df['Donchian_Low']) / 2
|
| 618 |
-
|
| 619 |
-
hl_range = df['High'] - df['Low']
|
| 620 |
-
mass_index = (hl_range.ewm(span=9).mean() / hl_range.ewm(span=9).mean().ewm(span=9).mean()).rolling(25).sum()
|
| 621 |
-
df['Mass_Index'] = mass_index
|
| 622 |
-
|
| 623 |
-
df['RVI'] = df['Close'].rolling(14).std() / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 624 |
-
|
| 625 |
-
atr_sum = df['True_Range'].rolling(14).sum()
|
| 626 |
-
high_low = np.log10(df['High'].rolling(14).max() / (df['Low'].rolling(14).min() + 1e-9))
|
| 627 |
-
df['CHOP'] = 100 * high_low / np.log10(14)
|
| 628 |
-
|
| 629 |
-
# ===== VOLUME INDICATORS (13) =====
|
| 630 |
-
df.ta.obv(append=True)
|
| 631 |
-
df.ta.ad(append=True)
|
| 632 |
-
df.ta.adosc(fast=3, slow=10, append=True)
|
| 633 |
-
df.ta.cmf(length=20, append=True)
|
| 634 |
-
|
| 635 |
-
df['VPT'] = (df['Close'].pct_change() * df['Volume']).cumsum()
|
| 636 |
-
df['Volume_SMA_20'] = df['Volume'].rolling(20).mean()
|
| 637 |
-
|
| 638 |
-
klinger_fast = df['Volume'].ewm(span=34).mean()
|
| 639 |
-
klinger_slow = df['Volume'].ewm(span=55).mean()
|
| 640 |
-
df['KO'] = klinger_fast - klinger_slow
|
| 641 |
-
|
| 642 |
-
distance_moved = (df['High'] + df['Low']) / 2 - (df['High'].shift(1) + df['Low'].shift(1)) / 2
|
| 643 |
-
box_height = df['High'] - df['Low']
|
| 644 |
-
eom = distance_moved / (box_height / df['Volume'])
|
| 645 |
-
df['EOM'] = eom.rolling(14).mean()
|
| 646 |
-
|
| 647 |
-
df['EFI'] = (df['Close'] - df['Close'].shift(1)) * df['Volume']
|
| 648 |
-
df['EFI'] = df['EFI'].ewm(span=13).mean()
|
| 649 |
-
|
| 650 |
-
tr = np.sqrt((df['High'] - df['Low'])**2 + (df['High'] - df['Close'].shift(1))**2 + (df['Low'] - df['Close'].shift(1))**2)
|
| 651 |
-
vm_plus = np.abs(df['High'] - df['Low'].shift(1))
|
| 652 |
-
vm_minus = np.abs(df['Low'] - df['High'].shift(1))
|
| 653 |
-
df['VI_Plus'] = vm_plus.rolling(14).sum() / tr.rolling(14).sum()
|
| 654 |
-
df['VI_Minus'] = vm_minus.rolling(14).sum() / tr.rolling(14).sum()
|
| 655 |
-
|
| 656 |
-
# ===== OSCILLATORS & ADDITIONAL =====
|
| 657 |
-
df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 658 |
-
df['Median_Price'] = (df['High'] + df['Low']) / 2
|
| 659 |
-
df['HL2'] = (df['High'] + df['Low']) / 2
|
| 660 |
-
df['OC2'] = (df['Open'] + df['Close']) / 2
|
| 661 |
-
df['HLC3'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 662 |
-
df['OHLC4'] = (df['Open'] + df['High'] + df['Low'] + df['Close']) / 4
|
| 663 |
-
|
| 664 |
-
df['Pivot'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 665 |
-
df['R1'] = 2 * df['Pivot'] - df['Low']
|
| 666 |
-
df['S1'] = 2 * df['Pivot'] - df['High']
|
| 667 |
-
df['R2'] = df['Pivot'] + (df['High'] - df['Low'])
|
| 668 |
-
df['S2'] = df['Pivot'] - (df['High'] - df['Low'])
|
| 669 |
-
|
| 670 |
-
weights = np.arange(1, 21)
|
| 671 |
-
df['WMA'] = df['Close'].rolling(20).apply(lambda x: np.sum(x * weights) / np.sum(weights))
|
| 672 |
-
df['RMA'] = df['Close'].ewm(span=20, adjust=False).mean()
|
| 673 |
-
df['VWMA'] = (df['Close'] * df['Volume']).rolling(20).sum() / df['Volume'].rolling(20).sum()
|
| 674 |
-
|
| 675 |
-
df['Price_Position'] = (df['Close'] - df['Low'].rolling(14).min()) / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 676 |
-
df['Oscillator_Ratio'] = (df['Close'] - df['SMA_20']) / df['STDEV_20']
|
| 677 |
-
|
| 678 |
-
df['Price_vs_SMA20'] = df['Close'] - df['SMA_20']
|
| 679 |
-
df['Price_vs_EMA12'] = df['Close'] - df['EMA_12']
|
| 680 |
-
df['EMA12_vs_EMA26'] = df['EMA_12'] - df['EMA_26']
|
| 681 |
-
df['SMA50_vs_SMA200'] = df['SMA_50'] - df['SMA_200']
|
| 682 |
-
|
| 683 |
-
df['RSI_Divergence'] = df['RSI_14'].diff()
|
| 684 |
-
df['Volume_Divergence'] = df['Volume'].diff()
|
| 685 |
-
|
| 686 |
-
df['Momentum_Strength'] = df['RSI_14'].rolling(5).std()
|
| 687 |
-
df['Trend_Strength'] = df['ADX_14']
|
| 688 |
-
|
| 689 |
-
df['Close_Change'] = df['Close'].pct_change()
|
| 690 |
-
df['High_Low_Ratio'] = (df['High'] - df['Low']) / df['Close']
|
| 691 |
-
df['Close_Open_Pct'] = (df['Close'] - df['Open']) / df['Open']
|
| 692 |
-
df['Volume_MA_Ratio'] = df['Volume'] / df['Volume'].rolling(20).mean()
|
| 693 |
-
|
| 694 |
-
return df
|
| 695 |
-
|
| 696 |
-
def create_features(self):
|
| 697 |
-
logging.info("Calculating technical indicators (105 total) and other features...")
|
| 698 |
-
|
| 699 |
-
col_mapping = {
|
| 700 |
-
'OPEN_PRICE': 'Open',
|
| 701 |
-
'HIGH_PRICE': 'High',
|
| 702 |
-
'LOW_PRICE': 'Low',
|
| 703 |
-
'CLOSE_PRICE': 'Close',
|
| 704 |
-
'NET_TRDQTY': 'Volume'
|
| 705 |
-
}
|
| 706 |
-
|
| 707 |
-
# Ensure correct column types
|
| 708 |
-
float_columns = ['OPEN_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'CLOSE_PRICE', 'NET_TRDQTY', 'PREV_CL_PR']
|
| 709 |
-
for col in float_columns:
|
| 710 |
-
if col in self.df.columns:
|
| 711 |
-
self.df[col] = self.df[col].astype('float64')
|
| 712 |
-
|
| 713 |
-
grouped = self.df.groupby('SYMBOL', observed=False)
|
| 714 |
-
tasks = [(symbol, group, col_mapping) for symbol, group in grouped if len(group) >= 200]
|
| 715 |
-
|
| 716 |
-
import multiprocessing
|
| 717 |
-
# Use available CPU cores minus 2 for efficiency and system responsiveness
|
| 718 |
-
num_cores = max(1, multiprocessing.cpu_count() - 2)
|
| 719 |
-
logging.info(f"Processing indicators in parallel using {num_cores} cores...")
|
| 720 |
-
|
| 721 |
-
processed_groups = []
|
| 722 |
-
with multiprocessing.Pool(processes=num_cores) as pool:
|
| 723 |
-
for group_res in tqdm(pool.imap(_calculate_indicators_for_group, tasks), total=len(tasks), desc="Calculating indicators per symbol"):
|
| 724 |
-
processed_groups.append(group_res)
|
| 725 |
-
|
| 726 |
-
self.df = pd.concat(processed_groups, ignore_index=True)
|
| 727 |
-
|
| 728 |
-
# Add NIFTY50 Index features (2 features)
|
| 729 |
-
logging.info("Checking for NIFTY50 index data...")
|
| 730 |
-
if 'NIFTY50' in self.df['SYMBOL'].values:
|
| 731 |
-
logging.info("Calculating NIFTY50 index features...")
|
| 732 |
-
nifty_df = self.df[self.df['SYMBOL'] == 'NIFTY50'].copy()
|
| 733 |
-
nifty_df = nifty_df[['TRAN_DATE', 'CLOSE_PRICE']].rename(columns={'CLOSE_PRICE': 'NIFTY50_CLOSE'})
|
| 734 |
-
self.df = self.df.merge(nifty_df, on='TRAN_DATE', how='left')
|
| 735 |
-
self.df['NIFTY50_RETURN'] = self.df.groupby('SYMBOL', observed=False)['NIFTY50_CLOSE'].pct_change() * 100
|
| 736 |
-
self.df['NIFTY50_RETURN'] = self.df['NIFTY50_RETURN'].fillna(0.0)
|
| 737 |
-
else:
|
| 738 |
-
self.df['NIFTY50_CLOSE'] = 0.0
|
| 739 |
-
self.df['NIFTY50_RETURN'] = 0.0
|
| 740 |
-
|
| 741 |
-
# Replace infinity and NaNs
|
| 742 |
-
self.df.replace([np.inf, -np.inf], np.nan, inplace=True)
|
| 743 |
-
# Safe fillna/bfill for numeric columns
|
| 744 |
-
numeric_cols = self.df.select_dtypes(include=[np.number]).columns
|
| 745 |
-
self.df[numeric_cols] = self.df[numeric_cols].bfill().fillna(0.0)
|
| 746 |
-
|
| 747 |
-
def create_target(self):
|
| 748 |
-
# Add a column for the symbol name
|
| 749 |
-
self.df['SYMBOL'] = self.df['SYMBOL'].astype('category')
|
| 750 |
-
# Ensure the DataFrame is sorted by SYMBOL and TRAN_DATE
|
| 751 |
-
self.df.sort_values(by=['SYMBOL', 'TRAN_DATE'], inplace=True)
|
| 752 |
-
self.df.reset_index(drop=True, inplace=True)
|
| 753 |
-
|
| 754 |
-
# Calculate and populate RECOMMEND column using the chosen strategy
|
| 755 |
-
# Options: 'strategy' (Ultra Profit Maximizer), 'forward' (future price movement), 'simple' (basic up/down/flat)
|
| 756 |
-
target_creator_type = 'simple' # Options: 'forward', 'simple'
|
| 757 |
-
logging.info(f"Generating Type: {target_creator_type} target price for model training...")
|
| 758 |
-
|
| 759 |
-
from tqdm import tqdm
|
| 760 |
-
tqdm.pandas(desc="Generating target prices per symbol")
|
| 761 |
-
if target_creator_type == 'forward':
|
| 762 |
-
self.df = self.df.groupby('SYMBOL').progress_apply(
|
| 763 |
-
self._create_forward_target,
|
| 764 |
-
lookahead_days=1
|
| 765 |
-
).reset_index(drop=True)
|
| 766 |
-
elif target_creator_type == 'simple':
|
| 767 |
-
self.df = self.df.groupby('SYMBOL', observed=False).progress_apply(
|
| 768 |
-
self._create_simple_target,
|
| 769 |
-
lookahead_days=1
|
| 770 |
-
).reset_index(drop=True)
|
| 771 |
-
|
| 772 |
-
# Verify if TARGET_PRICES column is created
|
| 773 |
-
if 'TARGET_PRICES' not in self.df.columns:
|
| 774 |
-
logging.error("TARGET_PRICES column not found after target creation...")
|
| 775 |
-
|
| 776 |
-
def run_correlation_analysis(self, df):
|
| 777 |
-
try:
|
| 778 |
-
print("\nRunning correlation analysis.")
|
| 779 |
-
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
|
| 780 |
-
|
| 781 |
-
# Drop rows with NaNs to ensure clean correlation
|
| 782 |
-
clean_df = df[numeric_cols].dropna()
|
| 783 |
-
|
| 784 |
-
# Compute correlation matrix
|
| 785 |
-
corr_matrix = clean_df.corr()
|
| 786 |
-
|
| 787 |
-
# Focus on correlation with CLOSE_PRICE
|
| 788 |
-
target_corr = corr_matrix['CLOSE_PRICE'].sort_values(ascending=False)
|
| 789 |
-
|
| 790 |
-
print("\n📈 Correlation with CLOSE_PRICE:")
|
| 791 |
-
print(target_corr.to_string(float_format=lambda x: f"{x:.4f}"))
|
| 792 |
-
|
| 793 |
-
# Optionally, show top 5 positively and negatively correlated features
|
| 794 |
-
print("\n⬆️ Top 5 positively correlated features:")
|
| 795 |
-
print(target_corr.drop('CLOSE_PRICE').head(5).to_string(float_format=lambda x: f"{x:.4f}"))
|
| 796 |
-
|
| 797 |
-
print("\n⬇️ Top 5 negatively correlated features:")
|
| 798 |
-
print(target_corr.drop('CLOSE_PRICE').tail(5).to_string(float_format=lambda x: f"{x:.4f}"))
|
| 799 |
-
|
| 800 |
-
# Mutual Information Analysis
|
| 801 |
-
print("\nCalculating Mutual Information Scores.")
|
| 802 |
-
X = clean_df.drop(columns=['CLOSE_PRICE'], errors='ignore')
|
| 803 |
-
y = clean_df['CLOSE_PRICE']
|
| 804 |
-
scaler = MinMaxScaler()
|
| 805 |
-
X_scaled = scaler.fit_transform(X)
|
| 806 |
-
|
| 807 |
-
mi_scores = mutual_info_regression(X_scaled, y)
|
| 808 |
-
mi_series = pd.Series(mi_scores, index=X.columns).sort_values(ascending=False)
|
| 809 |
-
|
| 810 |
-
print("\n📊 Mutual Information Scores:")
|
| 811 |
-
print(mi_series.to_string(float_format=lambda x: f"{x:.4f}"))
|
| 812 |
-
|
| 813 |
-
except Exception as e:
|
| 814 |
-
logging.error(f"Correlation analysis failed: {e}")
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
def save_extract_file(self):
|
| 818 |
-
# Final cleanup and save to Parquet
|
| 819 |
-
print("Cleaning up data before saving...")
|
| 820 |
-
logging.info("Saving processed data to Parquet file...")
|
| 821 |
-
self.df.dropna(inplace=True)
|
| 822 |
-
print("Sorting data by SYMBOL and TRAN_DATE...")
|
| 823 |
-
self.df.sort_values(by=['SYMBOL', 'TRAN_DATE'], inplace=True)
|
| 824 |
-
self.df.reset_index(drop=True, inplace=True)
|
| 825 |
-
print(f"Saving to {self.extract_file.name} (compression='snappy')...")
|
| 826 |
-
self.df.to_parquet(self.extract_file, index=False, compression='snappy')
|
| 827 |
-
print("Saving extract file completed successfully.")
|
| 828 |
-
logging.info(f"Processed data saved to {self.extract_file}")
|
| 829 |
-
|
| 830 |
-
def load_data(self, label_creator_type = 'simple'):
|
| 831 |
-
"""
|
| 832 |
-
Loads stock data from CSV, performs date filtering, and calculates
|
| 833 |
-
various technical indicators and lag features.
|
| 834 |
-
"""
|
| 835 |
-
logging.info("Loading stock data... This may take a moment.")
|
| 836 |
-
try:
|
| 837 |
-
# Define data types for efficient memory usage and accurate loading
|
| 838 |
-
dtypes = {
|
| 839 |
-
'SYMBOL': 'category', 'TRAN_DATE': str, 'OPEN_PRICE': 'float32',
|
| 840 |
-
'HIGH_PRICE': 'float32', 'LOW_PRICE': 'float32', 'PREV_CL_PR': 'float32',
|
| 841 |
-
'CLOSE_PRICE': 'float32', 'NET_TRDVAL': 'float32', 'NET_TRDQTY': 'float32',
|
| 842 |
-
'TRADES': 'float32', 'HI_52_WK': 'float32', 'LO_52_WK': 'float32',
|
| 843 |
-
'HI_DIFF': 'float32', 'LO_DIFF': 'float32'
|
| 844 |
-
}
|
| 845 |
-
|
| 846 |
-
# Load data from Parquet
|
| 847 |
-
self.df = pd.read_parquet(
|
| 848 |
-
self.parquet_path,
|
| 849 |
-
columns=list(dtypes.keys())
|
| 850 |
-
)
|
| 851 |
-
|
| 852 |
-
self.df['TRAN_DATE'] = pd.to_datetime(self.df['TRAN_DATE'], errors='coerce')
|
| 853 |
-
self.df.dropna(subset=['TRAN_DATE'], inplace=True)
|
| 854 |
-
|
| 855 |
-
self.first_date = self.df['TRAN_DATE'].min()
|
| 856 |
-
self.last_date = self.df['TRAN_DATE'].max()
|
| 857 |
-
|
| 858 |
-
# Filter data for the last two years
|
| 859 |
-
scale_data = False
|
| 860 |
-
training_days = 365 * 2 # Years of data to consider for scaling
|
| 861 |
-
if scale_data:
|
| 862 |
-
self.scaled_date = self.last_date - timedelta(days=training_days)
|
| 863 |
-
logging.info(f"Processing transaction dates and filtering data from: {self.scaled_date.strftime('%Y-%m-%d')}...")
|
| 864 |
-
mask = self.df['TRAN_DATE'] >= self.scaled_date
|
| 865 |
-
latest_data = self.df[mask].copy()
|
| 866 |
-
self.df = latest_data
|
| 867 |
-
else:
|
| 868 |
-
logging.info(f"Processing transaction dates and filtering data from: {self.first_date.strftime('%Y-%m-%d')}...")
|
| 869 |
-
|
| 870 |
-
# Sort data by SYMBOL and TRAN_DATE
|
| 871 |
-
self.df = self.df.sort_values(by=['SYMBOL', 'TRAN_DATE']).reset_index(drop=True)
|
| 872 |
-
|
| 873 |
-
is_ti = getattr(self, 'ti_only', False) or not self.extract_file.exists()
|
| 874 |
-
if is_ti:
|
| 875 |
-
# Create feature set from dataframe
|
| 876 |
-
logging.info(f"Generating features and labels for model training on complete dataset...")
|
| 877 |
-
self.create_features()
|
| 878 |
-
print("Generating target prices.")
|
| 879 |
-
self.create_target()
|
| 880 |
-
# Drop null values from dataframe and drop index
|
| 881 |
-
self.df.dropna(inplace=True)
|
| 882 |
-
print("Saving extract file.")
|
| 883 |
-
self.save_extract_file()
|
| 884 |
-
print("Extract data generated and saved successfully.")
|
| 885 |
-
else:
|
| 886 |
-
logging.info("Skipping technical indicators recalculation (use -ti switch to recalculate).")
|
| 887 |
-
|
| 888 |
-
logging.info("Data loading and preprocessing completed successfully...")
|
| 889 |
-
# Show correlation analysis results
|
| 890 |
-
#self.run_correlation_analysis(self.df)
|
| 891 |
-
# Log the final DataFrame statistics
|
| 892 |
-
logging.info(f"Data loaded with {len(self.df)} rows and {len(self.df.columns)} columns.")
|
| 893 |
-
# Show all the column from dataframe
|
| 894 |
-
logging.info(f"Data columns: {', '.join(self.df.columns)}")
|
| 895 |
-
logging.info(f"Data contains {self.df['SYMBOL'].nunique()} unique symbols.")
|
| 896 |
-
logging.info(f"Data last date: {self.last_date.strftime('%Y-%m-%d')}, Data first date: {self.first_date.strftime('%Y-%m-%d')}")
|
| 897 |
-
logging.info(f"Data contains {self.df['TRAN_DATE'].nunique()} unique transaction dates.")
|
| 898 |
-
# Show the first and lst few rows of the DataFrame for verification
|
| 899 |
-
logging.info(f"First 5 rows of the DataFrame:\n{self.df.head()}")
|
| 900 |
-
logging.info(f"Last 5 rows of the DataFrame:\n{self.df.tail()}")
|
| 901 |
-
|
| 902 |
-
except FileNotFoundError:
|
| 903 |
-
logging.error(f"Error: Input file not found at {self.input_file}. Please ensure 'nse_processed_data.csv' exists.")
|
| 904 |
-
sys.exit(1) # Exit if the critical input file is missing
|
| 905 |
-
except Exception as e:
|
| 906 |
-
logging.error(f"Failed to load and preprocess data: {e}")
|
| 907 |
-
sys.exit(1) # Exit on general data processing failure
|
| 908 |
-
|
| 909 |
-
def apply_corporate_actions(self, df):
|
| 910 |
-
"""
|
| 911 |
-
Adjusts historical prices (OPEN_PRICE,HIGH_PRICE,LOW_PRICE,CLOSE_PRICE,PREV_CL_PR,HI_52_WK,LO_52_WK)
|
| 912 |
-
in the DataFrame based on corporate actions. Returns the adjusted DataFrame and the count of symbols for which actions were applied.
|
| 913 |
-
"""
|
| 914 |
-
adjusted_symbols_count = 0
|
| 915 |
-
|
| 916 |
-
# Load corporate actions from JSON file
|
| 917 |
-
try:
|
| 918 |
-
if not self.corpact_file.exists():
|
| 919 |
-
self.logger.info(f"Corporate actions file not found: {self.corpact_file}. Skipping corporate actions.")
|
| 920 |
-
return df, 0 # Return original DataFrame if file not found
|
| 921 |
-
|
| 922 |
-
with open(self.corpact_file, 'r') as f:
|
| 923 |
-
self.corporate_actions = json.load(f)
|
| 924 |
-
|
| 925 |
-
if not self.corporate_actions:
|
| 926 |
-
self.logger.info("No corporate actions found in file.")
|
| 927 |
-
return df, 0 # Return original DataFrame if no actions are defined
|
| 928 |
-
except FileNotFoundError:
|
| 929 |
-
self.logger.error(f"Corporate actions file not found: {self.corpact_file}")
|
| 930 |
-
return df, 0
|
| 931 |
-
except json.JSONDecodeError:
|
| 932 |
-
self.logger.error(f"Error decoding corporate actions JSON from {self.corpact_file}. Check JSON format.")
|
| 933 |
-
return df, 0
|
| 934 |
-
except Exception as e:
|
| 935 |
-
self.logger.error(f"Failed to load corporate actions: {str(e)}")
|
| 936 |
-
return df, 0
|
| 937 |
-
|
| 938 |
-
df_adjusted = df.copy()
|
| 939 |
-
df_adjusted['TRAN_DATE'] = pd.to_datetime(df_adjusted['TRAN_DATE']) # Ensure datetime type
|
| 940 |
-
|
| 941 |
-
print("Applying corporate actions...", end='', flush=True)
|
| 942 |
-
# Sort corporate actions by date in descending order to apply older actions first
|
| 943 |
-
# This ensures that if multiple actions for the same symbol happen, the adjustments cascade correctly
|
| 944 |
-
self.corporate_actions.sort(key=lambda x: datetime.strptime(x['date'], '%Y-%m-%d'), reverse=True)
|
| 945 |
-
|
| 946 |
-
for action in tqdm(self.corporate_actions, desc="Applying corporate actions"):
|
| 947 |
-
symbol = action['symbol']
|
| 948 |
-
action_date = datetime.strptime(action['date'], '%Y-%m-%d')
|
| 949 |
-
ratio_str = action['ratio']
|
| 950 |
-
|
| 951 |
-
# Corrected part: Check if 'type' key exists and its value is not None
|
| 952 |
-
if 'type' not in action or action['type'] is None:
|
| 953 |
-
self.logger.warning(f"Corporate action for {symbol} on {action_date.strftime('%Y-%m-%d')} is missing 'type' or 'type' is null. Skipping.")
|
| 954 |
-
continue
|
| 955 |
-
|
| 956 |
-
action_type = action['type'].lower() # Now it's safe to call .lower()
|
| 957 |
-
|
| 958 |
-
# Parse ratio (e.g., "1:2" for split, "1:1" for bonus)
|
| 959 |
-
try:
|
| 960 |
-
if action_type == "split":
|
| 961 |
-
old_shares, new_shares = map(int, ratio_str.split(':'))
|
| 962 |
-
adjustment_factor = new_shares / old_shares
|
| 963 |
-
elif action_type == "bonus":
|
| 964 |
-
bonus_shares, held_shares = map(int, ratio_str.split(':'))
|
| 965 |
-
adjustment_factor = (held_shares + bonus_shares) / held_shares
|
| 966 |
-
else:
|
| 967 |
-
self.logger.warning(f"Unsupported corporate action type: {action_type} for {symbol} on {action_date.strftime('%Y-%m-%d')}. Skipping.")
|
| 968 |
-
continue
|
| 969 |
-
except ValueError:
|
| 970 |
-
self.logger.error(f"Invalid ratio format for {symbol} {action_type}: {ratio_str}. Skipping.")
|
| 971 |
-
continue
|
| 972 |
-
|
| 973 |
-
# Select data for the specific symbol and dates *before* the corporate action
|
| 974 |
-
# Prices before the action date need to be adjusted
|
| 975 |
-
mask = (df_adjusted['SYMBOL'] == symbol) & (df_adjusted['TRAN_DATE'] < action_date)
|
| 976 |
-
|
| 977 |
-
if df_adjusted[mask].empty:
|
| 978 |
-
continue
|
| 979 |
-
|
| 980 |
-
# Apply adjustment to relevant price columns
|
| 981 |
-
# HI_52_WK and LO_52_WK are adjusted here as well
|
| 982 |
-
price_columns = ['OPEN_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'CLOSE_PRICE', 'PREV_CL_PR', 'HI_52_WK', 'LO_52_WK']
|
| 983 |
-
|
| 984 |
-
for col in price_columns:
|
| 985 |
-
if col in df_adjusted.columns:
|
| 986 |
-
# Apply adjustment. Rounding to 2 decimal places for prices.
|
| 987 |
-
df_adjusted.loc[mask, col] = (df_adjusted.loc[mask, col] / adjustment_factor).round(2).astype('float32')
|
| 988 |
-
|
| 989 |
-
adjusted_symbols_count += 1
|
| 990 |
-
self.logger.info(f"Applied {action_type} action for {symbol} on {action_date.strftime('%Y-%m-%d')}. Factor: {adjustment_factor:.2f}")
|
| 991 |
-
|
| 992 |
-
print("Done")
|
| 993 |
-
return df_adjusted, adjusted_symbols_count
|
| 994 |
-
|
| 995 |
-
def validate_schema(self, df):
|
| 996 |
-
"""Validate and convert DataFrame against schema with safe type conversion"""
|
| 997 |
-
try:
|
| 998 |
-
validated_df = pd.DataFrame()
|
| 999 |
-
|
| 1000 |
-
for col, dtype in self.schema.items():
|
| 1001 |
-
if col in df.columns:
|
| 1002 |
-
try:
|
| 1003 |
-
# Handle integer columns safely
|
| 1004 |
-
if dtype in ['int32', 'int64']:
|
| 1005 |
-
# First convert to float to handle NaN values
|
| 1006 |
-
validated_df[col] = pd.to_numeric(df[col], errors='coerce')
|
| 1007 |
-
# Then convert to integer, replacing NaN with 0
|
| 1008 |
-
validated_df[col] = validated_df[col].fillna(0).astype(dtype)
|
| 1009 |
-
else:
|
| 1010 |
-
validated_df[col] = df[col].astype(dtype)
|
| 1011 |
-
except Exception as e:
|
| 1012 |
-
self.logger.warning(f"Type conversion failed for {col} to {dtype}: {str(e)}. Keeping original type.")
|
| 1013 |
-
# If conversion fails, keep original column but ensure it's copied
|
| 1014 |
-
validated_df[col] = df[col]
|
| 1015 |
-
|
| 1016 |
-
return validated_df
|
| 1017 |
-
|
| 1018 |
-
except Exception as e:
|
| 1019 |
-
self.logger.error(f"Schema validation error: {str(e)}")
|
| 1020 |
-
return None
|
| 1021 |
-
|
| 1022 |
-
def save_processed_data(self, df):
|
| 1023 |
-
"""Save processed data with proper sorting and validation"""
|
| 1024 |
-
try:
|
| 1025 |
-
if df.empty:
|
| 1026 |
-
raise ValueError("No data to save")
|
| 1027 |
-
|
| 1028 |
-
# Check if this is fresh processing
|
| 1029 |
-
is_fresh_save = not self.parquet_path.exists()
|
| 1030 |
-
|
| 1031 |
-
# Ensure proper date format
|
| 1032 |
-
df['TRAN_DATE'] = pd.to_datetime(df['TRAN_DATE'])
|
| 1033 |
-
|
| 1034 |
-
# If existing file, merge with new data
|
| 1035 |
-
if not is_fresh_save:
|
| 1036 |
-
self.logger.info("Reading existing data for merge...")
|
| 1037 |
-
existing_df = pd.read_parquet(self.parquet_path)
|
| 1038 |
-
if not pd.api.types.is_datetime64_any_dtype(existing_df['TRAN_DATE']):
|
| 1039 |
-
existing_df['TRAN_DATE'] = pd.to_datetime(existing_df['TRAN_DATE'])
|
| 1040 |
-
|
| 1041 |
-
# Concatenate and remove duplicates
|
| 1042 |
-
self.logger.info("Merging with existing data...")
|
| 1043 |
-
df = pd.concat([existing_df, df], ignore_index=True)
|
| 1044 |
-
df = df.drop_duplicates(subset=['TRAN_DATE', 'SYMBOL'], keep='last')
|
| 1045 |
-
|
| 1046 |
-
# Convert SYMBOL to category for efficient sorting
|
| 1047 |
-
df['SYMBOL'] = df['SYMBOL'].astype('category')
|
| 1048 |
-
|
| 1049 |
-
# Sort the final dataset
|
| 1050 |
-
self.logger.info("Sorting output data...")
|
| 1051 |
-
df = df.sort_values(
|
| 1052 |
-
by=['TRAN_DATE', 'SYMBOL'],
|
| 1053 |
-
ascending=[True, True]
|
| 1054 |
-
).reset_index(drop=True)
|
| 1055 |
-
|
| 1056 |
-
print("\nSaving processed data...", end='', flush=True)
|
| 1057 |
-
with tqdm(total=1, desc="Saving outputs") as pbar:
|
| 1058 |
-
# Save Parquet
|
| 1059 |
-
df.to_parquet(self.parquet_path, index=False)
|
| 1060 |
-
pbar.update(1)
|
| 1061 |
-
|
| 1062 |
-
# Log summary
|
| 1063 |
-
self.logger.info(
|
| 1064 |
-
f"{'Created new' if is_fresh_save else 'Updated'} output files\n"
|
| 1065 |
-
f"Total records: {len(df):,}\n"
|
| 1066 |
-
f"Date range: {df['TRAN_DATE'].min():%Y-%m-%d} to "
|
| 1067 |
-
f"{df['TRAN_DATE'].max():%Y-%m-%d}\n"
|
| 1068 |
-
f"Files:\n{self.parquet_path}"
|
| 1069 |
-
)
|
| 1070 |
-
|
| 1071 |
-
# Print summary
|
| 1072 |
-
print(f"\nData {'created' if is_fresh_save else 'updated'} successfully")
|
| 1073 |
-
print(f"Records: {len(df):,}")
|
| 1074 |
-
print(f"Date range: {df['TRAN_DATE'].min():%Y-%m-%d} to "
|
| 1075 |
-
f"{df['TRAN_DATE'].max():%Y-%m-%d}")
|
| 1076 |
-
|
| 1077 |
-
# Return final DataFrame
|
| 1078 |
-
return df
|
| 1079 |
-
|
| 1080 |
-
except Exception as e:
|
| 1081 |
-
self.logger.error(f"Failed to save output: {str(e)}")
|
| 1082 |
-
raise
|
| 1083 |
-
return None
|
| 1084 |
-
|
| 1085 |
-
def process_all_files(self):
|
| 1086 |
-
"""
|
| 1087 |
-
Process files with simplified progress display,
|
| 1088 |
-
and optionally apply corporate actions for full processing runs.
|
| 1089 |
-
"""
|
| 1090 |
-
self.metrics['start_time'] = time.time()
|
| 1091 |
-
|
| 1092 |
-
# Determine if this is a full processing run
|
| 1093 |
-
latest_date = self.get_latest_processed_date()
|
| 1094 |
-
is_full_processing = (latest_date is None)
|
| 1095 |
-
|
| 1096 |
-
# Find and sort ZIP files
|
| 1097 |
-
zip_files = []
|
| 1098 |
-
for zip_path in self.downloads_dir.rglob("*.zip"):
|
| 1099 |
-
try:
|
| 1100 |
-
date_str = self.extract_date_from_filename(zip_path.name)
|
| 1101 |
-
if date_str:
|
| 1102 |
-
file_date = pd.to_datetime(date_str)
|
| 1103 |
-
if latest_date is None or file_date > latest_date:
|
| 1104 |
-
zip_files.append((file_date, zip_path))
|
| 1105 |
-
except Exception:
|
| 1106 |
-
# Log the error but continue if a single file has issues
|
| 1107 |
-
self.logger.warning(f"Could not extract date or process filename for {zip_path.name}. Skipping.")
|
| 1108 |
-
continue
|
| 1109 |
-
|
| 1110 |
-
zip_files.sort()
|
| 1111 |
-
self.new_dates = [d for d, _ in zip_files]
|
| 1112 |
-
zip_files = [p for _, p in zip_files]
|
| 1113 |
-
|
| 1114 |
-
if not zip_files:
|
| 1115 |
-
print("No new files to process.")
|
| 1116 |
-
if is_full_processing: # If no files and it's a full run, maybe apply corporate actions to existing data
|
| 1117 |
-
print("However, performing corporate action check on existing data (if any).")
|
| 1118 |
-
try:
|
| 1119 |
-
# Load existing data if available to apply corporate actions
|
| 1120 |
-
if self.parquet_path.exists():
|
| 1121 |
-
final_df = pd.read_parquet(self.parquet_path)
|
| 1122 |
-
if not pd.api.types.is_datetime64_any_dtype(final_df['TRAN_DATE']):
|
| 1123 |
-
final_df['TRAN_DATE'] = pd.to_datetime(final_df['TRAN_DATE'])
|
| 1124 |
-
final_df, applied_count = self.apply_corporate_actions(final_df)
|
| 1125 |
-
self.metrics['corporate_actions_applied_count'] = applied_count
|
| 1126 |
-
if applied_count > 0:
|
| 1127 |
-
print(f"\nApplied {applied_count} corporate actions to existing data.")
|
| 1128 |
-
self.save_processed_data(final_df) # Save updated data
|
| 1129 |
-
else:
|
| 1130 |
-
print("No new corporate actions applied to existing data.")
|
| 1131 |
-
else:
|
| 1132 |
-
print("No existing data found for corporate action application.")
|
| 1133 |
-
except Exception as e:
|
| 1134 |
-
self.logger.error(f"Error during corporate action application on existing data: {str(e)}")
|
| 1135 |
-
print(f"Error applying corporate actions to existing data: {str(e)}")
|
| 1136 |
-
# Check if extract file exists, if not then try to load data
|
| 1137 |
-
if not self.extract_file.exists():
|
| 1138 |
-
print("No extract file found, generating now.")
|
| 1139 |
-
try:
|
| 1140 |
-
self.load_data()
|
| 1141 |
-
except Exception as e:
|
| 1142 |
-
self.logger.error(f"Error loading extract data: {str(e)}")
|
| 1143 |
-
print(f"Error loading extract data: {str(e)}")
|
| 1144 |
-
elif getattr(self, 'ti_only', False):
|
| 1145 |
-
print("Recalculating technical indicators for the complete data sets...")
|
| 1146 |
-
try:
|
| 1147 |
-
self.load_data()
|
| 1148 |
-
except Exception as e:
|
| 1149 |
-
self.logger.error(f"Error recalculating technical indicators: {str(e)}")
|
| 1150 |
-
print(f"Error recalculating technical indicators: {str(e)}")
|
| 1151 |
-
return
|
| 1152 |
-
|
| 1153 |
-
print(f"Processing {len(zip_files)} files...", end='', flush=True)
|
| 1154 |
-
|
| 1155 |
-
# Process in batches
|
| 1156 |
-
batch_size = 10
|
| 1157 |
-
processed_batches = []
|
| 1158 |
-
|
| 1159 |
-
with tqdm(total=len(zip_files), desc="Processing ZIP files", bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt}') as pbar:
|
| 1160 |
-
for i in range(0, len(zip_files), batch_size):
|
| 1161 |
-
batch = zip_files[i:i + batch_size]
|
| 1162 |
-
batch_data = []
|
| 1163 |
-
|
| 1164 |
-
for zip_file in batch:
|
| 1165 |
-
try:
|
| 1166 |
-
df = self.process_zip_file(zip_file)
|
| 1167 |
-
if df is not None and not df.empty:
|
| 1168 |
-
batch_data.append(df)
|
| 1169 |
-
self.metrics['files_processed'] += 1
|
| 1170 |
-
except Exception as e:
|
| 1171 |
-
logging.warning(f"Failed to process {zip_file}: {e}")
|
| 1172 |
-
finally:
|
| 1173 |
-
pbar.update(1)
|
| 1174 |
-
|
| 1175 |
-
if batch_data:
|
| 1176 |
-
batch_df = pd.concat(batch_data, ignore_index=True)
|
| 1177 |
-
processed_batches.append(batch_df)
|
| 1178 |
-
gc.collect()
|
| 1179 |
-
|
| 1180 |
-
if processed_batches:
|
| 1181 |
-
print("\nMerging data...", end='', flush=True)
|
| 1182 |
-
final_df = pd.concat(processed_batches, ignore_index=True)
|
| 1183 |
-
# Sorting will happen inside save_processed_data as well if merging with existing.
|
| 1184 |
-
# However, for consistency before corporate actions, we can sort here too.
|
| 1185 |
-
final_df = final_df.sort_values(['TRAN_DATE', 'SYMBOL'])
|
| 1186 |
-
print("Done")
|
| 1187 |
-
|
| 1188 |
-
# Apply corporate actions if it's a full processing run
|
| 1189 |
-
if is_full_processing:
|
| 1190 |
-
print("It's a full processing run. Checking for corporate actions...")
|
| 1191 |
-
final_df, applied_count = self.apply_corporate_actions(final_df)
|
| 1192 |
-
self.metrics['corporate_actions_applied_count'] = applied_count
|
| 1193 |
-
if applied_count > 0:
|
| 1194 |
-
print(f"Successfully applied {applied_count} corporate actions.")
|
| 1195 |
-
self.logger.info(f"Applied {applied_count} corporate actions during full processing.")
|
| 1196 |
-
else:
|
| 1197 |
-
print("No corporate actions found or applied.")
|
| 1198 |
-
self.logger.info("No corporate actions found or applied during full processing.")
|
| 1199 |
-
|
| 1200 |
-
# Save processed data
|
| 1201 |
-
df = self.save_processed_data(final_df)
|
| 1202 |
-
|
| 1203 |
-
# Reload data to ensure consistency
|
| 1204 |
-
self.load_data()
|
| 1205 |
-
|
| 1206 |
-
"""
|
| 1207 |
-
# Update self.df and create features/target
|
| 1208 |
-
if df is not None:
|
| 1209 |
-
self.df = df
|
| 1210 |
-
self.create_features()
|
| 1211 |
-
self.create_target()
|
| 1212 |
-
self.save_extract_file()
|
| 1213 |
-
else:
|
| 1214 |
-
print("No data to save after processing.")
|
| 1215 |
-
self.logger.warning("No data to save after processing.")
|
| 1216 |
-
"""
|
| 1217 |
-
|
| 1218 |
-
duration = time.time() - self.metrics['start_time']
|
| 1219 |
-
self.metrics['duration'] = duration
|
| 1220 |
-
self.metrics['end_datetime'] = datetime.now()
|
| 1221 |
-
start_dt = datetime.fromtimestamp(self.metrics['start_time']).strftime('%Y-%m-%d %H:%M:%S')
|
| 1222 |
-
|
| 1223 |
-
print(f"\n--- Processing Summary ---")
|
| 1224 |
-
print(f"Recalculate Technical Indicators (-ti): {'Yes (Complete Datasets)' if getattr(self, 'ti_only', False) else ('Yes (Extract File Missing)' if not self.extract_file.exists() else 'No (Skipped)')}")
|
| 1225 |
-
print(f"Started: {start_dt}")
|
| 1226 |
-
print(f"Ended: {self.metrics['end_datetime'].strftime('%Y-%m-%d %H:%M:%S')}")
|
| 1227 |
-
print(f"Duration: {self.metrics['duration']:.1f}s")
|
| 1228 |
-
print(f"Files Found: {len(zip_files)}") # This is the count of files considered for processing
|
| 1229 |
-
print(f"Files Processed (new/updated): {self.metrics['files_processed']}")
|
| 1230 |
-
print(f"Files Failed: {self.metrics['files_failed']}")
|
| 1231 |
-
print(f"Rows Processed: {self.metrics['rows_processed']:,}")
|
| 1232 |
-
if is_full_processing:
|
| 1233 |
-
print(f"Corporate Actions Applied: {self.metrics['corporate_actions_applied_count']}")
|
| 1234 |
-
print(f"--------------------------")
|
| 1235 |
-
|
| 1236 |
-
|
| 1237 |
-
def main():
|
| 1238 |
-
import argparse
|
| 1239 |
-
parser = argparse.ArgumentParser(description="NSE Data Processor with Technical Indicators switch")
|
| 1240 |
-
parser.add_argument("-ti", "--ti", action="store_true", help="Recalculate technical indicators only for the current data file")
|
| 1241 |
-
args = parser.parse_args()
|
| 1242 |
-
|
| 1243 |
-
try:
|
| 1244 |
-
processor = NSEDataProcessor()
|
| 1245 |
-
processor.ti_only = args.ti
|
| 1246 |
-
processor.process_all_files()
|
| 1247 |
-
except KeyboardInterrupt:
|
| 1248 |
-
print("\nProcessing interrupted by user.")
|
| 1249 |
-
except Exception as e:
|
| 1250 |
-
print(f"\nAn unhandled error occurred: {str(e)}")
|
| 1251 |
-
logging.exception("An unhandled error occurred in main execution block.")
|
| 1252 |
-
sys.exit(1)
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
if __name__ == "__main__":
|
| 1256 |
-
main()
|
| 1257 |
-
|
| 1258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/__init__.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import warnings
|
| 3 |
-
sys.original_warn = warnings.warn
|
| 4 |
-
|
| 5 |
-
from flask import Flask
|
| 6 |
-
|
| 7 |
-
# Configure TensorFlow to only use CPU to avoid CUDA compatibility issues with compute capability 12.0
|
| 8 |
-
try:
|
| 9 |
-
import tensorflow as tf
|
| 10 |
-
tf.config.set_visible_devices([], 'GPU')
|
| 11 |
-
except Exception:
|
| 12 |
-
pass
|
| 13 |
-
|
| 14 |
-
def create_app():
|
| 15 |
-
app = Flask(__name__)
|
| 16 |
-
|
| 17 |
-
# Import and register routes
|
| 18 |
-
from . import routes
|
| 19 |
-
app.register_blueprint(routes.bp)
|
| 20 |
-
|
| 21 |
-
return app
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/agent_workflow.py
DELETED
|
@@ -1,229 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import json
|
| 3 |
-
import logging
|
| 4 |
-
import asyncio
|
| 5 |
-
from typing import Dict, TypedDict, Any
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 8 |
-
|
| 9 |
-
from langchain_core.language_models.fake import FakeListLLM
|
| 10 |
-
from langchain_core.tools import tool
|
| 11 |
-
from crewai import Agent, Task, Crew, Process
|
| 12 |
-
from langgraph.graph import StateGraph, START, END
|
| 13 |
-
|
| 14 |
-
# Import the managers
|
| 15 |
-
from nsereco.routes import get_managers
|
| 16 |
-
|
| 17 |
-
# Define the state for LangGraph
|
| 18 |
-
class WorkflowState(TypedDict):
|
| 19 |
-
task_type: str
|
| 20 |
-
num_symbols: int
|
| 21 |
-
worker_threads: int
|
| 22 |
-
results: str
|
| 23 |
-
status: str
|
| 24 |
-
|
| 25 |
-
# Create a Fake LLM for CrewAI to run offline
|
| 26 |
-
fake_llm = FakeListLLM(responses=["I will now execute the python function."])
|
| 27 |
-
|
| 28 |
-
# ==========================================
|
| 29 |
-
# CrewAI Agents & Tools Definition
|
| 30 |
-
# ==========================================
|
| 31 |
-
|
| 32 |
-
@tool
|
| 33 |
-
def execute_optuna_tuning_tool(num_symbols: int, max_workers: int) -> str:
|
| 34 |
-
"""Tool to execute Optuna tuning prioritizing NIFTY stocks."""
|
| 35 |
-
sm, rec = get_managers()
|
| 36 |
-
tracker_path = sm.data_dir / 'symbol_master_tracker.json'
|
| 37 |
-
|
| 38 |
-
# Load tracker
|
| 39 |
-
if not tracker_path.exists():
|
| 40 |
-
return "symbol_master_tracker.json not found."
|
| 41 |
-
|
| 42 |
-
with open(tracker_path, 'r') as f:
|
| 43 |
-
tracker = json.load(f)
|
| 44 |
-
|
| 45 |
-
# Filter pending tuning
|
| 46 |
-
pending = [sym for sym, data in tracker.items() if data.get("tuning_status") == "PENDING"]
|
| 47 |
-
# Prioritize nifty stocks
|
| 48 |
-
nifty_pending = [sym for sym in pending if tracker[sym].get("nifty_indicator") is True]
|
| 49 |
-
non_nifty_pending = [sym for sym in pending if tracker[sym].get("nifty_indicator") is False]
|
| 50 |
-
|
| 51 |
-
target_symbols = (nifty_pending + non_nifty_pending)[:num_symbols]
|
| 52 |
-
|
| 53 |
-
if not target_symbols:
|
| 54 |
-
return "No pending symbols for tuning."
|
| 55 |
-
|
| 56 |
-
logging.info(f"Agent executing Optuna tuning for {len(target_symbols)} symbols: {target_symbols}")
|
| 57 |
-
|
| 58 |
-
from nsereco.task_manager import task_manager
|
| 59 |
-
task_manager.update_progress(0, len(target_symbols))
|
| 60 |
-
|
| 61 |
-
# Run tuning (this runs sequentially or using its internal threads)
|
| 62 |
-
for idx, sym in enumerate(target_symbols):
|
| 63 |
-
try:
|
| 64 |
-
tracker[sym]["tuning_status"] = "IN_PROGRESS"
|
| 65 |
-
with open(tracker_path, 'w') as f:
|
| 66 |
-
json.dump(tracker, f, indent=4)
|
| 67 |
-
# Recommender function for tuning actually tunes all passed symbols, but we can pass just one at a time, or all.
|
| 68 |
-
# wait, run_optuna_tuning takes num_symbols. It will pick from NIFTY stocks.
|
| 69 |
-
# To force it to tune our target_symbols, we need to adapt it, but since we are controlling it, we can just use train_symbol or run_optuna_tuning.
|
| 70 |
-
# Actually, run_optuna_tuning does randomly pick from NIFTY. Let's just update run_optuna_tuning to accept a list of symbols if we wanted, or we just let it run.
|
| 71 |
-
rec.run_optuna_tuning(sm, num_trials=20, num_symbols=1, max_workers=max_workers, tuning_symbols=[sym])
|
| 72 |
-
task_manager.update_progress(idx + 1, len(target_symbols))
|
| 73 |
-
except Exception as e:
|
| 74 |
-
logging.error(f"Error tuning {sym}: {e}")
|
| 75 |
-
tracker[sym]["tuning_status"] = "FAILED"
|
| 76 |
-
with open(tracker_path, 'w') as f:
|
| 77 |
-
json.dump(tracker, f, indent=4)
|
| 78 |
-
task_manager.update_progress(idx + 1, len(target_symbols))
|
| 79 |
-
|
| 80 |
-
return f"Optuna tuning completed for {target_symbols}"
|
| 81 |
-
|
| 82 |
-
@tool
|
| 83 |
-
def execute_model_training_tool(num_symbols: int, max_workers: int) -> str:
|
| 84 |
-
"""Tool to execute Model training using best hyperparameters prioritizing NIFTY stocks."""
|
| 85 |
-
sm, rec = get_managers()
|
| 86 |
-
tracker_path = sm.data_dir / 'symbol_master_tracker.json'
|
| 87 |
-
|
| 88 |
-
if not tracker_path.exists():
|
| 89 |
-
return "symbol_master_tracker.json not found."
|
| 90 |
-
|
| 91 |
-
with open(tracker_path, 'r') as f:
|
| 92 |
-
tracker = json.load(f)
|
| 93 |
-
|
| 94 |
-
# Filter tuning COMPLETED and training PENDING
|
| 95 |
-
pending = [sym for sym, data in tracker.items()
|
| 96 |
-
if data.get("tuning_status") == "COMPLETED" and data.get("training_status") == "PENDING"]
|
| 97 |
-
|
| 98 |
-
# Prioritize nifty stocks
|
| 99 |
-
nifty_pending = [sym for sym in pending if tracker[sym].get("nifty_indicator") is True]
|
| 100 |
-
non_nifty_pending = [sym for sym in pending if tracker[sym].get("nifty_indicator") is False]
|
| 101 |
-
|
| 102 |
-
target_symbols = (nifty_pending + non_nifty_pending)[:num_symbols]
|
| 103 |
-
|
| 104 |
-
if not target_symbols:
|
| 105 |
-
return "No pending symbols for training."
|
| 106 |
-
|
| 107 |
-
logging.info(f"Agent executing Model training for {len(target_symbols)} symbols: {target_symbols}")
|
| 108 |
-
|
| 109 |
-
from nsereco.task_manager import task_manager
|
| 110 |
-
task_manager.update_progress(0, len(target_symbols))
|
| 111 |
-
|
| 112 |
-
for idx, sym in enumerate(target_symbols):
|
| 113 |
-
try:
|
| 114 |
-
tracker[sym]["training_status"] = "IN_PROGRESS"
|
| 115 |
-
with open(tracker_path, 'w') as f:
|
| 116 |
-
json.dump(tracker, f, indent=4)
|
| 117 |
-
|
| 118 |
-
# Fetch best parameters
|
| 119 |
-
best_params = tracker[sym].get("optuna_best_parameters")
|
| 120 |
-
if not best_params:
|
| 121 |
-
best_params = rec.best_hyperparameters
|
| 122 |
-
|
| 123 |
-
# We use the train_symbol or run_model_training_all_stocks logic here
|
| 124 |
-
sym_df = sm.df[sm.df['SYMBOL'] == sym].copy()
|
| 125 |
-
rec.train_model(sym, sym_df, sm, best_params, "agent_training")
|
| 126 |
-
|
| 127 |
-
# Mark as completed
|
| 128 |
-
tracker[sym]["training_status"] = "COMPLETED"
|
| 129 |
-
with open(tracker_path, 'w') as f:
|
| 130 |
-
json.dump(tracker, f, indent=4)
|
| 131 |
-
task_manager.update_progress(idx + 1, len(target_symbols))
|
| 132 |
-
|
| 133 |
-
except Exception as e:
|
| 134 |
-
logging.error(f"Error training {sym}: {e}")
|
| 135 |
-
tracker[sym]["training_status"] = "FAILED"
|
| 136 |
-
with open(tracker_path, 'w') as f:
|
| 137 |
-
json.dump(tracker, f, indent=4)
|
| 138 |
-
task_manager.update_progress(idx + 1, len(target_symbols))
|
| 139 |
-
|
| 140 |
-
return f"Model training completed for {target_symbols}"
|
| 141 |
-
|
| 142 |
-
# Define Agents
|
| 143 |
-
tuning_agent = Agent(
|
| 144 |
-
role='Hyperparameter Tuning Optimizer',
|
| 145 |
-
goal='Optimize hyperparameters for stock prediction models.',
|
| 146 |
-
backstory='You are an expert AI responsible for tuning stock models to perfection.',
|
| 147 |
-
tools=[],
|
| 148 |
-
allow_delegation=False,
|
| 149 |
-
llm="fake"
|
| 150 |
-
)
|
| 151 |
-
|
| 152 |
-
training_agent = Agent(
|
| 153 |
-
role='Model Trainer',
|
| 154 |
-
goal='Train predictive models using the best hyperparameters.',
|
| 155 |
-
backstory='You are an expert AI data scientist responsible for training robust models.',
|
| 156 |
-
tools=[],
|
| 157 |
-
allow_delegation=False,
|
| 158 |
-
llm="fake"
|
| 159 |
-
)
|
| 160 |
-
|
| 161 |
-
# ==========================================
|
| 162 |
-
# LangGraph Workflow Definition
|
| 163 |
-
# ==========================================
|
| 164 |
-
|
| 165 |
-
async def optuna_tuning_node(state: WorkflowState) -> WorkflowState:
|
| 166 |
-
num_symbols = state['num_symbols']
|
| 167 |
-
worker_threads = state['worker_threads']
|
| 168 |
-
|
| 169 |
-
# Execute the tool directly
|
| 170 |
-
result = execute_optuna_tuning_tool.invoke({"num_symbols": num_symbols, "max_workers": worker_threads})
|
| 171 |
-
|
| 172 |
-
return {"results": result, "status": "COMPLETED"}
|
| 173 |
-
|
| 174 |
-
async def model_training_node(state: WorkflowState) -> WorkflowState:
|
| 175 |
-
num_symbols = state['num_symbols']
|
| 176 |
-
worker_threads = state['worker_threads']
|
| 177 |
-
|
| 178 |
-
result = execute_model_training_tool.invoke({"num_symbols": num_symbols, "max_workers": worker_threads})
|
| 179 |
-
|
| 180 |
-
return {"results": result, "status": "COMPLETED"}
|
| 181 |
-
|
| 182 |
-
def route_task(state: WorkflowState):
|
| 183 |
-
if state['task_type'] == 'tune':
|
| 184 |
-
return 'optuna_tuning_node'
|
| 185 |
-
elif state['task_type'] == 'train':
|
| 186 |
-
return 'model_training_node'
|
| 187 |
-
return END
|
| 188 |
-
|
| 189 |
-
# Build LangGraph
|
| 190 |
-
workflow = StateGraph(WorkflowState)
|
| 191 |
-
workflow.add_node("optuna_tuning_node", optuna_tuning_node)
|
| 192 |
-
workflow.add_node("model_training_node", model_training_node)
|
| 193 |
-
workflow.add_conditional_edges(START, route_task)
|
| 194 |
-
workflow.add_edge("optuna_tuning_node", END)
|
| 195 |
-
workflow.add_edge("model_training_node", END)
|
| 196 |
-
|
| 197 |
-
app = workflow.compile()
|
| 198 |
-
|
| 199 |
-
# ==========================================
|
| 200 |
-
# Orchestrator Function
|
| 201 |
-
# ==========================================
|
| 202 |
-
|
| 203 |
-
def run_agent_workflow(task_type: str, num_symbols: int, worker_threads: int):
|
| 204 |
-
"""
|
| 205 |
-
Entry point to trigger the workflow. Runs asynchronously.
|
| 206 |
-
"""
|
| 207 |
-
import asyncio
|
| 208 |
-
|
| 209 |
-
async def _run():
|
| 210 |
-
initial_state = {
|
| 211 |
-
"task_type": task_type,
|
| 212 |
-
"num_symbols": num_symbols,
|
| 213 |
-
"worker_threads": worker_threads,
|
| 214 |
-
"results": "",
|
| 215 |
-
"status": "STARTED"
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
logging.info(f"Starting Agent Workflow: Task={task_type}, Symbols={num_symbols}")
|
| 219 |
-
final_state = await app.ainvoke(initial_state)
|
| 220 |
-
logging.info(f"Workflow Finished. Result: {final_state['results']}")
|
| 221 |
-
|
| 222 |
-
try:
|
| 223 |
-
loop = asyncio.get_running_loop()
|
| 224 |
-
# If we are in an already running loop (like within a web worker thread sometimes), we can create a task
|
| 225 |
-
loop.create_task(_run())
|
| 226 |
-
except RuntimeError:
|
| 227 |
-
loop = asyncio.new_event_loop()
|
| 228 |
-
asyncio.set_event_loop(loop)
|
| 229 |
-
loop.run_until_complete(_run())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/data.py
DELETED
|
@@ -1,749 +0,0 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
import logging
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
from datetime import datetime, timedelta
|
| 5 |
-
import json
|
| 6 |
-
import gc
|
| 7 |
-
import warnings
|
| 8 |
-
import numpy as np
|
| 9 |
-
import tensorflow as tf
|
| 10 |
-
from tensorflow.keras.models import Sequential, load_model
|
| 11 |
-
from tensorflow.keras.optimizers import Adam
|
| 12 |
-
from tensorflow.keras.callbacks import LearningRateScheduler, EarlyStopping, ModelCheckpoint
|
| 13 |
-
from tensorflow.keras.layers import Input, LSTM, Bidirectional, Dense, Dropout, BatchNormalization, Conv1D, GRU, Attention, Concatenate
|
| 14 |
-
from tensorflow.keras.models import Model
|
| 15 |
-
from tensorflow.keras.callbacks import ReduceLROnPlateau, Callback
|
| 16 |
-
import tensorflow.keras.backend as K
|
| 17 |
-
import optuna
|
| 18 |
-
from optuna.integration import TFKerasPruningCallback
|
| 19 |
-
from rich.table import Table
|
| 20 |
-
from rich.console import Console
|
| 21 |
-
from rich import box
|
| 22 |
-
import matplotlib.pyplot as plt
|
| 23 |
-
import seaborn as sns
|
| 24 |
-
import threading
|
| 25 |
-
|
| 26 |
-
matplotlib_lock = threading.Lock()
|
| 27 |
-
from sklearn.preprocessing import MinMaxScaler
|
| 28 |
-
from sklearn.utils.class_weight import compute_class_weight
|
| 29 |
-
from sklearn.model_selection import train_test_split
|
| 30 |
-
import time
|
| 31 |
-
import sys
|
| 32 |
-
import os
|
| 33 |
-
import functools
|
| 34 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
|
| 35 |
-
import threading
|
| 36 |
-
from multiprocessing import Pool
|
| 37 |
-
from itertools import product
|
| 38 |
-
import signal
|
| 39 |
-
import talib
|
| 40 |
-
import pandas_ta_classic as ta
|
| 41 |
-
import fastparquet
|
| 42 |
-
|
| 43 |
-
import torch
|
| 44 |
-
import torch.nn as nn
|
| 45 |
-
import torch.nn.functional as F
|
| 46 |
-
from torch.utils.data import DataLoader, TensorDataset
|
| 47 |
-
from torch.optim import Adam
|
| 48 |
-
from torch.nn import HuberLoss
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def _calculate_indicators_for_group(group_data_tuple):
|
| 52 |
-
symbol, group, col_mapping = group_data_tuple
|
| 53 |
-
df = group.copy()
|
| 54 |
-
|
| 55 |
-
# Rename columns to match pandas_ta expectations
|
| 56 |
-
df = df.rename(columns=col_mapping)
|
| 57 |
-
|
| 58 |
-
# ===== MOMENTUM INDICATORS (14) =====
|
| 59 |
-
df.ta.rsi(length=14, append=True)
|
| 60 |
-
df.ta.macd(fast=12, slow=26, signal=9, append=True)
|
| 61 |
-
stoch = df.ta.stoch(length=14, k=3, d=3, append=True)
|
| 62 |
-
df.ta.willr(length=14, append=True)
|
| 63 |
-
df.ta.cmo(length=14, append=True)
|
| 64 |
-
df.ta.cci(length=20, append=True)
|
| 65 |
-
df.ta.roc(length=12, append=True)
|
| 66 |
-
df.ta.mfi(length=14, append=True)
|
| 67 |
-
df.ta.fisher(length=9, append=True)
|
| 68 |
-
df.ta.kst(append=True)
|
| 69 |
-
df.ta.mom(length=10, append=True)
|
| 70 |
-
df.ta.tsi(long=25, short=13, append=True)
|
| 71 |
-
df.ta.uo(fast=7, medium=14, slow=28, append=True)
|
| 72 |
-
df.ta.ao(append=True)
|
| 73 |
-
|
| 74 |
-
# ===== TREND INDICATORS (18) =====
|
| 75 |
-
df.ta.sma(length=20, append=True)
|
| 76 |
-
df.ta.sma(length=50, append=True)
|
| 77 |
-
df.ta.sma(length=200, append=True)
|
| 78 |
-
df.ta.ema(length=12, append=True)
|
| 79 |
-
df.ta.ema(length=26, append=True)
|
| 80 |
-
df.ta.ema(length=50, append=True)
|
| 81 |
-
df.ta.ema(length=200, append=True)
|
| 82 |
-
df.ta.adx(length=14, append=True)
|
| 83 |
-
df.ta.aroon(length=14, append=True)
|
| 84 |
-
df.ta.supertrend(length=10, multiplier=3, append=True)
|
| 85 |
-
df.ta.psar(af0=0.02, af=0.2, append=True)
|
| 86 |
-
df.ta.alma(length=9, append=True)
|
| 87 |
-
df.ta.dema(length=10, append=True)
|
| 88 |
-
df.ta.tema(length=10, append=True)
|
| 89 |
-
df.ta.hma(length=9, append=True)
|
| 90 |
-
df.ta.kama(length=10, append=True)
|
| 91 |
-
df.ta.linreg(length=25, append=True)
|
| 92 |
-
|
| 93 |
-
# ===== VOLATILITY INDICATORS (14) =====
|
| 94 |
-
df['True_Range'] = np.maximum(
|
| 95 |
-
np.maximum(
|
| 96 |
-
df['High'] - df['Low'],
|
| 97 |
-
np.abs(df['High'] - df['Close'].shift())
|
| 98 |
-
),
|
| 99 |
-
np.abs(df['Low'] - df['Close'].shift())
|
| 100 |
-
)
|
| 101 |
-
|
| 102 |
-
bbands = df.ta.bbands(length=20, std=2, append=True)
|
| 103 |
-
df.ta.atr(length=14, append=True)
|
| 104 |
-
df.ta.natr(length=14, append=True)
|
| 105 |
-
df.ta.kc(length=20, scalar=2, append=True)
|
| 106 |
-
df.ta.stdev(length=20, append=True)
|
| 107 |
-
|
| 108 |
-
df['Historical_Volatility'] = df['Close'].pct_change().rolling(20).std() * np.sqrt(252)
|
| 109 |
-
df['Donchian_High'] = df['High'].rolling(20).max()
|
| 110 |
-
df['Donchian_Low'] = df['Low'].rolling(20).min()
|
| 111 |
-
df['Donchian_Mid'] = (df['Donchian_High'] + df['Donchian_Low']) / 2
|
| 112 |
-
|
| 113 |
-
hl_range = df['High'] - df['Low']
|
| 114 |
-
mass_index = (hl_range.ewm(span=9).mean() / hl_range.ewm(span=9).mean().ewm(span=9).mean()).rolling(25).sum()
|
| 115 |
-
df['Mass_Index'] = mass_index
|
| 116 |
-
|
| 117 |
-
df['RVI'] = df['Close'].rolling(14).std() / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 118 |
-
|
| 119 |
-
atr_sum = df['True_Range'].rolling(14).sum()
|
| 120 |
-
high_low = np.log10(df['High'].rolling(14).max() / (df['Low'].rolling(14).min() + 1e-9))
|
| 121 |
-
df['CHOP'] = 100 * high_low / np.log10(14)
|
| 122 |
-
|
| 123 |
-
# ===== VOLUME INDICATORS (13) =====
|
| 124 |
-
df.ta.obv(append=True)
|
| 125 |
-
df.ta.ad(append=True)
|
| 126 |
-
df.ta.adosc(fast=3, slow=10, append=True)
|
| 127 |
-
df.ta.cmf(length=20, append=True)
|
| 128 |
-
|
| 129 |
-
df['VPT'] = (df['Close'].pct_change() * df['Volume']).cumsum()
|
| 130 |
-
df['Volume_SMA_20'] = df['Volume'].rolling(20).mean()
|
| 131 |
-
|
| 132 |
-
klinger_fast = df['Volume'].ewm(span=34).mean()
|
| 133 |
-
klinger_slow = df['Volume'].ewm(span=55).mean()
|
| 134 |
-
df['KO'] = klinger_fast - klinger_slow
|
| 135 |
-
|
| 136 |
-
distance_moved = (df['High'] + df['Low']) / 2 - (df['High'].shift(1) + df['Low'].shift(1)) / 2
|
| 137 |
-
box_height = df['High'] - df['Low']
|
| 138 |
-
eom = distance_moved / (box_height / df['Volume'])
|
| 139 |
-
df['EOM'] = eom.rolling(14).mean()
|
| 140 |
-
|
| 141 |
-
df['EFI'] = (df['Close'] - df['Close'].shift(1)) * df['Volume']
|
| 142 |
-
df['EFI'] = df['EFI'].ewm(span=13).mean()
|
| 143 |
-
|
| 144 |
-
tr = np.sqrt((df['High'] - df['Low'])**2 + (df['High'] - df['Close'].shift(1))**2 + (df['Low'] - df['Close'].shift(1))**2)
|
| 145 |
-
vm_plus = np.abs(df['High'] - df['Low'].shift(1))
|
| 146 |
-
vm_minus = np.abs(df['Low'] - df['High'].shift(1))
|
| 147 |
-
df['VI_Plus'] = vm_plus.rolling(14).sum() / tr.rolling(14).sum()
|
| 148 |
-
df['VI_Minus'] = vm_minus.rolling(14).sum() / tr.rolling(14).sum()
|
| 149 |
-
|
| 150 |
-
# ===== OSCILLATORS & ADDITIONAL =====
|
| 151 |
-
df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 152 |
-
df['Median_Price'] = (df['High'] + df['Low']) / 2
|
| 153 |
-
df['HL2'] = (df['High'] + df['Low']) / 2
|
| 154 |
-
df['OC2'] = (df['Open'] + df['Close']) / 2
|
| 155 |
-
df['HLC3'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 156 |
-
df['OHLC4'] = (df['Open'] + df['High'] + df['Low'] + df['Close']) / 4
|
| 157 |
-
|
| 158 |
-
df['Pivot'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 159 |
-
df['R1'] = 2 * df['Pivot'] - df['Low']
|
| 160 |
-
df['S1'] = 2 * df['Pivot'] - df['High']
|
| 161 |
-
df['R2'] = df['Pivot'] + (df['High'] - df['Low'])
|
| 162 |
-
df['S2'] = df['Pivot'] - (df['High'] - df['Low'])
|
| 163 |
-
|
| 164 |
-
weights = np.arange(1, 21)
|
| 165 |
-
df['WMA'] = df['Close'].rolling(20).apply(lambda x: np.sum(x * weights) / np.sum(weights))
|
| 166 |
-
df['RMA'] = df['Close'].ewm(span=20, adjust=False).mean()
|
| 167 |
-
df['VWMA'] = (df['Close'] * df['Volume']).rolling(20).sum() / df['Volume'].rolling(20).sum()
|
| 168 |
-
|
| 169 |
-
df['Price_Position'] = (df['Close'] - df['Low'].rolling(14).min()) / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 170 |
-
df['Oscillator_Ratio'] = (df['Close'] - df['SMA_20']) / df['STDEV_20']
|
| 171 |
-
|
| 172 |
-
df['Price_vs_SMA20'] = df['Close'] - df['SMA_20']
|
| 173 |
-
df['Price_vs_EMA12'] = df['Close'] - df['EMA_12']
|
| 174 |
-
df['EMA12_vs_EMA26'] = df['EMA_12'] - df['EMA_26']
|
| 175 |
-
df['SMA50_vs_SMA200'] = df['SMA_50'] - df['SMA_200']
|
| 176 |
-
|
| 177 |
-
df['RSI_Divergence'] = df['RSI_14'].diff()
|
| 178 |
-
df['Volume_Divergence'] = df['Volume'].diff()
|
| 179 |
-
|
| 180 |
-
df['Momentum_Strength'] = df['RSI_14'].rolling(5).std()
|
| 181 |
-
df['Trend_Strength'] = df['ADX_14']
|
| 182 |
-
|
| 183 |
-
df['Close_Change'] = df['Close'].pct_change()
|
| 184 |
-
df['High_Low_Ratio'] = (df['High'] - df['Low']) / df['Close']
|
| 185 |
-
df['Close_Open_Pct'] = (df['Close'] - df['Open']) / df['Open']
|
| 186 |
-
df['Volume_MA_Ratio'] = df['Volume'] / df['Volume'].rolling(20).mean()
|
| 187 |
-
|
| 188 |
-
# Revert column names to uppercase
|
| 189 |
-
inv_col_mapping = {v: k for k, v in col_mapping.items()}
|
| 190 |
-
df = df.rename(columns=inv_col_mapping)
|
| 191 |
-
|
| 192 |
-
# Add sentiment & news features (4 features)
|
| 193 |
-
df['Sentiment'] = 0.0
|
| 194 |
-
df['News_Count'] = 0.0
|
| 195 |
-
df['News_Sentiment'] = 0.0
|
| 196 |
-
df['News_Impact'] = 0.0
|
| 197 |
-
|
| 198 |
-
# Add hybrid strategy indicators (4 features)
|
| 199 |
-
df['VIX'] = 15.0
|
| 200 |
-
df['Vol_20MA'] = df['NET_TRDQTY'].rolling(window=20).mean().bfill().fillna(0.0)
|
| 201 |
-
df['Prev_High'] = df['HIGH_PRICE'].shift(1).bfill().fillna(0.0)
|
| 202 |
-
df['Breakout_Signal'] = ((df['CLOSE_PRICE'] > df['Prev_High']) & (df['NET_TRDQTY'] > df['Vol_20MA'])).astype(float)
|
| 203 |
-
|
| 204 |
-
return df
|
| 205 |
-
|
| 206 |
-
class StockData:
|
| 207 |
-
"""Manages loading, preprocessing, and feature engineering of stock data."""
|
| 208 |
-
def __init__(self, base_dir=None):
|
| 209 |
-
self.base_dir = Path(base_dir or Path.cwd())
|
| 210 |
-
self.setup_paths()
|
| 211 |
-
self.setup_logging()
|
| 212 |
-
self.df = pd.DataFrame() # Main DataFrame for engineered features
|
| 213 |
-
self.profit_df = pd.DataFrame() # DataFrame used for profit maximizer backtesting
|
| 214 |
-
self.load_data(label_creator_type = 'simple') # Load and preprocess data on initialization
|
| 215 |
-
self.init_symbol_master_tracker() # Initialize unified tracking JSON
|
| 216 |
-
|
| 217 |
-
def init_symbol_master_tracker(self):
|
| 218 |
-
"""Initializes the symbol_master_tracker.json file if it doesn't exist."""
|
| 219 |
-
from .utils import NIFTY_STOCKS, load_json_file, save_json_file
|
| 220 |
-
tracker_path = self.data_dir / 'symbol_master_tracker.json'
|
| 221 |
-
|
| 222 |
-
existing_data = load_json_file(tracker_path, {})
|
| 223 |
-
all_symbols = self.get_all_symbols_from_df()
|
| 224 |
-
|
| 225 |
-
updated = False
|
| 226 |
-
for sym in all_symbols:
|
| 227 |
-
if sym not in existing_data:
|
| 228 |
-
existing_data[sym] = {
|
| 229 |
-
"nifty_indicator": sym in NIFTY_STOCKS,
|
| 230 |
-
"optuna_best_parameters": None,
|
| 231 |
-
"tuning_status": "PENDING",
|
| 232 |
-
"training_status": "PENDING"
|
| 233 |
-
}
|
| 234 |
-
updated = True
|
| 235 |
-
|
| 236 |
-
if updated:
|
| 237 |
-
save_json_file(tracker_path, existing_data)
|
| 238 |
-
logging.info(f"Initialized/Updated symbol_master_tracker.json with {len(existing_data)} symbols.")
|
| 239 |
-
self.symbol_master_tracker_path = tracker_path
|
| 240 |
-
|
| 241 |
-
def _create_forward_target(self, group, lookahead_days=1):
|
| 242 |
-
"""
|
| 243 |
-
Creates a forward-looking regression target: the next closing price after `lookahead_days`.
|
| 244 |
-
Optionally allows for volatility computation or future annotation.
|
| 245 |
-
Returns a DataFrame with 'TARGET_PRICES' column containing single future prices.
|
| 246 |
-
"""
|
| 247 |
-
# Ensure chronological order
|
| 248 |
-
group = group.sort_values(by='TRAN_DATE').copy()
|
| 249 |
-
|
| 250 |
-
# Extract future single-step targets
|
| 251 |
-
close_prices = group['CLOSE_PRICE'].values
|
| 252 |
-
future_targets = []
|
| 253 |
-
|
| 254 |
-
for i in range(len(close_prices)):
|
| 255 |
-
if i + lookahead_days < len(close_prices):
|
| 256 |
-
future_targets.append(close_prices[i + lookahead_days])
|
| 257 |
-
else:
|
| 258 |
-
future_targets.append(np.nan)
|
| 259 |
-
|
| 260 |
-
group['TARGET_PRICES'] = future_targets
|
| 261 |
-
|
| 262 |
-
# Drop rows where TARGET_PRICES is NaN
|
| 263 |
-
group = group[~pd.isna(group['TARGET_PRICES'])].reset_index(drop=True)
|
| 264 |
-
|
| 265 |
-
return group
|
| 266 |
-
|
| 267 |
-
def _create_simple_target(self, group, lookahead_days=1):
|
| 268 |
-
"""
|
| 269 |
-
Creates a regression target for each row: the next closing price.
|
| 270 |
-
Returns a DataFrame with an added column 'TARGET_PRICES' containing a single future price.
|
| 271 |
-
"""
|
| 272 |
-
# Ensure chronological order
|
| 273 |
-
group = group.sort_values(by='TRAN_DATE').copy()
|
| 274 |
-
|
| 275 |
-
# Create single-step future target
|
| 276 |
-
future_targets = []
|
| 277 |
-
close_prices = group['CLOSE_PRICE'].values
|
| 278 |
-
|
| 279 |
-
for i in range(len(close_prices)):
|
| 280 |
-
if i + lookahead_days < len(close_prices):
|
| 281 |
-
future_targets.append(close_prices[i + lookahead_days])
|
| 282 |
-
else:
|
| 283 |
-
future_targets.append(np.nan)
|
| 284 |
-
|
| 285 |
-
group['TARGET_PRICES'] = future_targets
|
| 286 |
-
|
| 287 |
-
# Drop rows where TARGET_PRICES is NaN
|
| 288 |
-
group = group[~pd.isna(group['TARGET_PRICES'])].reset_index(drop=True)
|
| 289 |
-
|
| 290 |
-
return group
|
| 291 |
-
|
| 292 |
-
def setup_paths(self):
|
| 293 |
-
"""Sets up directory paths for data, models, and logs."""
|
| 294 |
-
self.data_dir = self.base_dir / 'processed'
|
| 295 |
-
self.recommender_dir = self.data_dir / 'recommender'
|
| 296 |
-
self.log_dir = self.base_dir / 'downloads'
|
| 297 |
-
self.watchlist_dir = self.data_dir / 'watchlists'
|
| 298 |
-
self.input_file = self.data_dir / 'nse_processed_data.csv'
|
| 299 |
-
self.extract_file = self.data_dir / 'nse_extract_data.parquet'
|
| 300 |
-
self.extract_csvfile = self.data_dir / 'nse_extract_data.csv'
|
| 301 |
-
self.recommender_dir.mkdir(parents=True, exist_ok=True)
|
| 302 |
-
self.data_dir.mkdir(parents=True, exist_ok=True)
|
| 303 |
-
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 304 |
-
|
| 305 |
-
def setup_logging(self):
|
| 306 |
-
"""Configures the logging system for console and file output."""
|
| 307 |
-
log_file_path = self.log_dir / 'nse_data_recommender.log'
|
| 308 |
-
# Remove existing handlers to prevent duplicate logs
|
| 309 |
-
for handler in logging.root.handlers[:]:
|
| 310 |
-
logging.root.removeHandler(handler)
|
| 311 |
-
|
| 312 |
-
logging.basicConfig(
|
| 313 |
-
level=logging.INFO,
|
| 314 |
-
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 315 |
-
handlers=[
|
| 316 |
-
logging.FileHandler(log_file_path),
|
| 317 |
-
logging.StreamHandler(sys.stdout)
|
| 318 |
-
]
|
| 319 |
-
)
|
| 320 |
-
# Suppress TensorFlow warnings for cleaner logs
|
| 321 |
-
tf.get_logger().setLevel('ERROR')
|
| 322 |
-
logging.getLogger('tensorflow').setLevel(logging.ERROR)
|
| 323 |
-
|
| 324 |
-
def get_all_symbols_from_df(self):
|
| 325 |
-
"""Returns a list of all unique stock symbols in the loaded DataFrame."""
|
| 326 |
-
if hasattr(self, 'df') and not self.df.empty:
|
| 327 |
-
return self.df['SYMBOL'].unique().tolist()
|
| 328 |
-
return []
|
| 329 |
-
|
| 330 |
-
@staticmethod
|
| 331 |
-
def run_correlation_analysis(df):
|
| 332 |
-
try:
|
| 333 |
-
logging.info("Running correlation analysis on engineered features...")
|
| 334 |
-
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
|
| 335 |
-
|
| 336 |
-
# Drop rows with NaNs to ensure clean correlation
|
| 337 |
-
clean_df = df[numeric_cols].dropna()
|
| 338 |
-
|
| 339 |
-
# Compute correlation matrix
|
| 340 |
-
corr_matrix = clean_df.corr()
|
| 341 |
-
|
| 342 |
-
# Focus on correlation with CLOSE_PRICE
|
| 343 |
-
target_corr = corr_matrix['CLOSE_PRICE'].sort_values(ascending=False)
|
| 344 |
-
|
| 345 |
-
print("\n📈 Correlation with CLOSE_PRICE:")
|
| 346 |
-
print(target_corr.to_string(float_format=lambda x: f"{x:.4f}"))
|
| 347 |
-
|
| 348 |
-
# Optionally, show top 5 positively and negatively correlated features
|
| 349 |
-
print("\n⬆️ Top 5 positively correlated features:")
|
| 350 |
-
print(target_corr.drop('CLOSE_PRICE').head(5).to_string(float_format=lambda x: f"{x:.4f}"))
|
| 351 |
-
|
| 352 |
-
print("\n⬇️ Top 5 negatively correlated features:")
|
| 353 |
-
print(target_corr.drop('CLOSE_PRICE').tail(5).to_string(float_format=lambda x: f"{x:.4f}"))
|
| 354 |
-
|
| 355 |
-
"""
|
| 356 |
-
# Mutual Information Analysis
|
| 357 |
-
X = clean_df.drop(columns=['CLOSE_PRICE'], errors='ignore')
|
| 358 |
-
y = clean_df['CLOSE_PRICE']
|
| 359 |
-
scaler = MinMaxScaler()
|
| 360 |
-
X_scaled = scaler.fit_transform(X)
|
| 361 |
-
|
| 362 |
-
mi_scores = mutual_info_regression(X_scaled, y)
|
| 363 |
-
mi_series = pd.Series(mi_scores, index=X.columns).sort_values(ascending=False)
|
| 364 |
-
|
| 365 |
-
print("\n📊 Mutual Information Scores:")
|
| 366 |
-
print(mi_series.to_string(float_format=lambda x: f"{x:.4f}"))
|
| 367 |
-
"""
|
| 368 |
-
|
| 369 |
-
except Exception as e:
|
| 370 |
-
logging.error(f"Correlation analysis failed: {e}")
|
| 371 |
-
|
| 372 |
-
def calculate_mlbench_indicators(self, df):
|
| 373 |
-
df = df.copy()
|
| 374 |
-
|
| 375 |
-
# ===== MOMENTUM INDICATORS (14) =====
|
| 376 |
-
df.ta.rsi(length=14, append=True)
|
| 377 |
-
df.ta.macd(fast=12, slow=26, signal=9, append=True)
|
| 378 |
-
stoch = df.ta.stoch(length=14, k=3, d=3, append=True)
|
| 379 |
-
df.ta.willr(length=14, append=True)
|
| 380 |
-
df.ta.cmo(length=14, append=True)
|
| 381 |
-
df.ta.cci(length=20, append=True)
|
| 382 |
-
df.ta.roc(length=12, append=True)
|
| 383 |
-
df.ta.mfi(length=14, append=True)
|
| 384 |
-
df.ta.fisher(length=9, append=True)
|
| 385 |
-
df.ta.kst(append=True)
|
| 386 |
-
df.ta.mom(length=10, append=True)
|
| 387 |
-
df.ta.tsi(long=25, short=13, append=True)
|
| 388 |
-
df.ta.uo(fast=7, medium=14, slow=28, append=True)
|
| 389 |
-
df.ta.ao(append=True)
|
| 390 |
-
|
| 391 |
-
# ===== TREND INDICATORS (18) =====
|
| 392 |
-
df.ta.sma(length=20, append=True)
|
| 393 |
-
df.ta.sma(length=50, append=True)
|
| 394 |
-
df.ta.sma(length=200, append=True)
|
| 395 |
-
df.ta.ema(length=12, append=True)
|
| 396 |
-
df.ta.ema(length=26, append=True)
|
| 397 |
-
df.ta.ema(length=50, append=True)
|
| 398 |
-
df.ta.ema(length=200, append=True)
|
| 399 |
-
df.ta.adx(length=14, append=True)
|
| 400 |
-
df.ta.aroon(length=14, append=True)
|
| 401 |
-
df.ta.supertrend(length=10, multiplier=3, append=True)
|
| 402 |
-
df.ta.psar(af0=0.02, af=0.2, append=True)
|
| 403 |
-
df.ta.alma(length=9, append=True)
|
| 404 |
-
df.ta.dema(length=10, append=True)
|
| 405 |
-
df.ta.tema(length=10, append=True)
|
| 406 |
-
df.ta.hma(length=9, append=True)
|
| 407 |
-
df.ta.kama(length=10, append=True)
|
| 408 |
-
df.ta.linreg(length=25, append=True)
|
| 409 |
-
|
| 410 |
-
# ===== VOLATILITY INDICATORS (14) =====
|
| 411 |
-
df['True_Range'] = np.maximum(
|
| 412 |
-
np.maximum(
|
| 413 |
-
df['High'] - df['Low'],
|
| 414 |
-
np.abs(df['High'] - df['Close'].shift())
|
| 415 |
-
),
|
| 416 |
-
np.abs(df['Low'] - df['Close'].shift())
|
| 417 |
-
)
|
| 418 |
-
|
| 419 |
-
bbands = df.ta.bbands(length=20, std=2, append=True)
|
| 420 |
-
df.ta.atr(length=14, append=True)
|
| 421 |
-
df.ta.natr(length=14, append=True)
|
| 422 |
-
df.ta.kc(length=20, scalar=2, append=True)
|
| 423 |
-
df.ta.stdev(length=20, append=True)
|
| 424 |
-
|
| 425 |
-
df['Historical_Volatility'] = df['Close'].pct_change().rolling(20).std() * np.sqrt(252)
|
| 426 |
-
df['Donchian_High'] = df['High'].rolling(20).max()
|
| 427 |
-
df['Donchian_Low'] = df['Low'].rolling(20).min()
|
| 428 |
-
df['Donchian_Mid'] = (df['Donchian_High'] + df['Donchian_Low']) / 2
|
| 429 |
-
|
| 430 |
-
hl_range = df['High'] - df['Low']
|
| 431 |
-
mass_index = (hl_range.ewm(span=9).mean() / hl_range.ewm(span=9).mean().ewm(span=9).mean()).rolling(25).sum()
|
| 432 |
-
df['Mass_Index'] = mass_index
|
| 433 |
-
|
| 434 |
-
df['RVI'] = df['Close'].rolling(14).std() / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 435 |
-
|
| 436 |
-
atr_sum = df['True_Range'].rolling(14).sum()
|
| 437 |
-
high_low = np.log10(df['High'].rolling(14).max() / (df['Low'].rolling(14).min() + 1e-9))
|
| 438 |
-
df['CHOP'] = 100 * high_low / np.log10(14)
|
| 439 |
-
|
| 440 |
-
# ===== VOLUME INDICATORS (13) =====
|
| 441 |
-
df.ta.obv(append=True)
|
| 442 |
-
df.ta.ad(append=True)
|
| 443 |
-
df.ta.adosc(fast=3, slow=10, append=True)
|
| 444 |
-
df.ta.cmf(length=20, append=True)
|
| 445 |
-
|
| 446 |
-
df['VPT'] = (df['Close'].pct_change() * df['Volume']).cumsum()
|
| 447 |
-
df['Volume_SMA_20'] = df['Volume'].rolling(20).mean()
|
| 448 |
-
|
| 449 |
-
klinger_fast = df['Volume'].ewm(span=34).mean()
|
| 450 |
-
klinger_slow = df['Volume'].ewm(span=55).mean()
|
| 451 |
-
df['KO'] = klinger_fast - klinger_slow
|
| 452 |
-
|
| 453 |
-
distance_moved = (df['High'] + df['Low']) / 2 - (df['High'].shift(1) + df['Low'].shift(1)) / 2
|
| 454 |
-
box_height = df['High'] - df['Low']
|
| 455 |
-
eom = distance_moved / (box_height / df['Volume'])
|
| 456 |
-
df['EOM'] = eom.rolling(14).mean()
|
| 457 |
-
|
| 458 |
-
df['EFI'] = (df['Close'] - df['Close'].shift(1)) * df['Volume']
|
| 459 |
-
df['EFI'] = df['EFI'].ewm(span=13).mean()
|
| 460 |
-
|
| 461 |
-
tr = np.sqrt((df['High'] - df['Low'])**2 + (df['High'] - df['Close'].shift(1))**2 + (df['Low'] - df['Close'].shift(1))**2)
|
| 462 |
-
vm_plus = np.abs(df['High'] - df['Low'].shift(1))
|
| 463 |
-
vm_minus = np.abs(df['Low'] - df['High'].shift(1))
|
| 464 |
-
df['VI_Plus'] = vm_plus.rolling(14).sum() / tr.rolling(14).sum()
|
| 465 |
-
df['VI_Minus'] = vm_minus.rolling(14).sum() / tr.rolling(14).sum()
|
| 466 |
-
|
| 467 |
-
# ===== OSCILLATORS & ADDITIONAL =====
|
| 468 |
-
df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 469 |
-
df['Median_Price'] = (df['High'] + df['Low']) / 2
|
| 470 |
-
df['HL2'] = (df['High'] + df['Low']) / 2
|
| 471 |
-
df['OC2'] = (df['Open'] + df['Close']) / 2
|
| 472 |
-
df['HLC3'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 473 |
-
df['OHLC4'] = (df['Open'] + df['High'] + df['Low'] + df['Close']) / 4
|
| 474 |
-
|
| 475 |
-
df['Pivot'] = (df['High'] + df['Low'] + df['Close']) / 3
|
| 476 |
-
df['R1'] = 2 * df['Pivot'] - df['Low']
|
| 477 |
-
df['S1'] = 2 * df['Pivot'] - df['High']
|
| 478 |
-
df['R2'] = df['Pivot'] + (df['High'] - df['Low'])
|
| 479 |
-
df['S2'] = df['Pivot'] - (df['High'] - df['Low'])
|
| 480 |
-
|
| 481 |
-
weights = np.arange(1, 21)
|
| 482 |
-
df['WMA'] = df['Close'].rolling(20).apply(lambda x: np.sum(x * weights) / np.sum(weights))
|
| 483 |
-
df['RMA'] = df['Close'].ewm(span=20, adjust=False).mean()
|
| 484 |
-
df['VWMA'] = (df['Close'] * df['Volume']).rolling(20).sum() / df['Volume'].rolling(20).sum()
|
| 485 |
-
|
| 486 |
-
df['Price_Position'] = (df['Close'] - df['Low'].rolling(14).min()) / (df['High'].rolling(14).max() - df['Low'].rolling(14).min() + 1e-9)
|
| 487 |
-
df['Oscillator_Ratio'] = (df['Close'] - df['SMA_20']) / df['STDEV_20']
|
| 488 |
-
|
| 489 |
-
df['Price_vs_SMA20'] = df['Close'] - df['SMA_20']
|
| 490 |
-
df['Price_vs_EMA12'] = df['Close'] - df['EMA_12']
|
| 491 |
-
df['EMA12_vs_EMA26'] = df['EMA_12'] - df['EMA_26']
|
| 492 |
-
df['SMA50_vs_SMA200'] = df['SMA_50'] - df['SMA_200']
|
| 493 |
-
|
| 494 |
-
df['RSI_Divergence'] = df['RSI_14'].diff()
|
| 495 |
-
df['Volume_Divergence'] = df['Volume'].diff()
|
| 496 |
-
|
| 497 |
-
df['Momentum_Strength'] = df['RSI_14'].rolling(5).std()
|
| 498 |
-
df['Trend_Strength'] = df['ADX_14']
|
| 499 |
-
|
| 500 |
-
df['Close_Change'] = df['Close'].pct_change()
|
| 501 |
-
df['High_Low_Ratio'] = (df['High'] - df['Low']) / df['Close']
|
| 502 |
-
df['Close_Open_Pct'] = (df['Close'] - df['Open']) / df['Open']
|
| 503 |
-
df['Volume_MA_Ratio'] = df['Volume'] / df['Volume'].rolling(20).mean()
|
| 504 |
-
|
| 505 |
-
return df
|
| 506 |
-
|
| 507 |
-
def create_features(self):
|
| 508 |
-
logging.info("Calculating technical indicators (105 total) and other features...")
|
| 509 |
-
|
| 510 |
-
col_mapping = {
|
| 511 |
-
'OPEN_PRICE': 'Open',
|
| 512 |
-
'HIGH_PRICE': 'High',
|
| 513 |
-
'LOW_PRICE': 'Low',
|
| 514 |
-
'CLOSE_PRICE': 'Close',
|
| 515 |
-
'NET_TRDQTY': 'Volume'
|
| 516 |
-
}
|
| 517 |
-
|
| 518 |
-
# Ensure correct column types
|
| 519 |
-
float_columns = ['OPEN_PRICE', 'HIGH_PRICE', 'LOW_PRICE', 'CLOSE_PRICE', 'NET_TRDQTY', 'PREV_CL_PR']
|
| 520 |
-
for col in float_columns:
|
| 521 |
-
if col in self.df.columns:
|
| 522 |
-
self.df[col] = self.df[col].astype('float64')
|
| 523 |
-
|
| 524 |
-
grouped = self.df.groupby('SYMBOL', observed=False)
|
| 525 |
-
tasks = [(symbol, group, col_mapping) for symbol, group in grouped if len(group) >= 200]
|
| 526 |
-
|
| 527 |
-
import multiprocessing
|
| 528 |
-
# Use available CPU cores minus 2 for efficiency and system responsiveness
|
| 529 |
-
num_cores = max(1, multiprocessing.cpu_count() - 2)
|
| 530 |
-
logging.info(f"Processing indicators in parallel using {num_cores} cores...")
|
| 531 |
-
|
| 532 |
-
from tqdm import tqdm
|
| 533 |
-
processed_groups = []
|
| 534 |
-
with multiprocessing.Pool(processes=num_cores) as pool:
|
| 535 |
-
for group_res in tqdm(pool.imap(_calculate_indicators_for_group, tasks), total=len(tasks), desc="Calculating indicators per symbol"):
|
| 536 |
-
processed_groups.append(group_res)
|
| 537 |
-
|
| 538 |
-
self.df = pd.concat(processed_groups, ignore_index=True)
|
| 539 |
-
|
| 540 |
-
# Add NIFTY50 Index features (2 features)
|
| 541 |
-
logging.info("Checking for NIFTY50 index data...")
|
| 542 |
-
if 'NIFTY50' in self.df['SYMBOL'].values:
|
| 543 |
-
logging.info("Calculating NIFTY50 index features...")
|
| 544 |
-
nifty_df = self.df[self.df['SYMBOL'] == 'NIFTY50'].copy()
|
| 545 |
-
nifty_df = nifty_df[['TRAN_DATE', 'CLOSE_PRICE']].rename(columns={'CLOSE_PRICE': 'NIFTY50_CLOSE'})
|
| 546 |
-
self.df = self.df.merge(nifty_df, on='TRAN_DATE', how='left')
|
| 547 |
-
self.df['NIFTY50_RETURN'] = self.df.groupby('SYMBOL', observed=False)['NIFTY50_CLOSE'].pct_change() * 100
|
| 548 |
-
self.df['NIFTY50_RETURN'] = self.df['NIFTY50_RETURN'].fillna(0.0)
|
| 549 |
-
else:
|
| 550 |
-
self.df['NIFTY50_CLOSE'] = 0.0
|
| 551 |
-
self.df['NIFTY50_RETURN'] = 0.0
|
| 552 |
-
|
| 553 |
-
# Replace infinity and NaNs
|
| 554 |
-
self.df.replace([np.inf, -np.inf], np.nan, inplace=True)
|
| 555 |
-
# Safe fillna/bfill for numeric columns
|
| 556 |
-
numeric_cols = self.df.select_dtypes(include=[np.number]).columns
|
| 557 |
-
self.df[numeric_cols] = self.df[numeric_cols].bfill().fillna(0.0)
|
| 558 |
-
|
| 559 |
-
def create_target(self):
|
| 560 |
-
# Add a column for the symbol name
|
| 561 |
-
self.df['SYMBOL'] = self.df['SYMBOL'].astype('category')
|
| 562 |
-
# Ensure the DataFrame is sorted by SYMBOL and TRAN_DATE
|
| 563 |
-
self.df.sort_values(by=['SYMBOL', 'TRAN_DATE'], inplace=True)
|
| 564 |
-
self.df.reset_index(drop=True, inplace=True)
|
| 565 |
-
|
| 566 |
-
# Calculate and populate RECOMMEND column using the chosen strategy
|
| 567 |
-
# Options: 'strategy' (Ultra Profit Maximizer), 'forward' (future price movement), 'simple' (basic up/down/flat)
|
| 568 |
-
target_creator_type = 'simple' # Options: 'forward', 'simple'
|
| 569 |
-
logging.info(f"Generating Type: {target_creator_type} target price for model training...")
|
| 570 |
-
|
| 571 |
-
if target_creator_type == 'forward':
|
| 572 |
-
self.df = self.df.groupby('SYMBOL').apply(
|
| 573 |
-
self._create_forward_target,
|
| 574 |
-
lookahead_days=1
|
| 575 |
-
).reset_index(drop=True)
|
| 576 |
-
elif target_creator_type == 'simple':
|
| 577 |
-
self.df = self.df.groupby('SYMBOL').apply(
|
| 578 |
-
self._create_simple_target,
|
| 579 |
-
lookahead_days=1
|
| 580 |
-
).reset_index(drop=True)
|
| 581 |
-
|
| 582 |
-
# Verify if TARGET_PRICES column is created
|
| 583 |
-
if 'TARGET_PRICES' not in self.df.columns:
|
| 584 |
-
logging.error("TARGET_PRICES column not found after target creation...")
|
| 585 |
-
|
| 586 |
-
def save_extract_file(self):
|
| 587 |
-
# Save the processed DataFrame to a CSV for future use
|
| 588 |
-
# Ensure the DataFrame is sorted by SYMBOL and TRAN_DATE
|
| 589 |
-
# Drop any rows with NaN in any column to ensure clean data
|
| 590 |
-
self.df.dropna(inplace=True)
|
| 591 |
-
self.df.sort_values(by=['SYMBOL', 'TRAN_DATE'], inplace=True)
|
| 592 |
-
self.df.reset_index(drop=True, inplace=True)
|
| 593 |
-
self.df.to_parquet(self.extract_file, index=False, compression='snappy')
|
| 594 |
-
# self.df.to_csv(self.extract_csvfile, index=False)
|
| 595 |
-
logging.info(f"Processed data saved to {self.extract_file}")
|
| 596 |
-
|
| 597 |
-
def load_data(self, label_creator_type = 'simple'):
|
| 598 |
-
"""
|
| 599 |
-
Loads stock data from CSV, performs date filtering, and calculates
|
| 600 |
-
various technical indicators and lag features.
|
| 601 |
-
"""
|
| 602 |
-
logging.info("Loading stock data... This may take a moment.")
|
| 603 |
-
try:
|
| 604 |
-
# Define data types for efficient memory usage and accurate loading
|
| 605 |
-
dtypes = {
|
| 606 |
-
'SYMBOL': 'category', 'TRAN_DATE': str, 'OPEN_PRICE': 'float32',
|
| 607 |
-
'HIGH_PRICE': 'float32', 'LOW_PRICE': 'float32', 'PREV_CL_PR': 'float32',
|
| 608 |
-
'CLOSE_PRICE': 'float32', 'NET_TRDVAL': 'float32', 'NET_TRDQTY': 'float32',
|
| 609 |
-
'TRADES': 'float32', 'HI_52_WK': 'float32', 'LO_52_WK': 'float32',
|
| 610 |
-
'HI_DIFF': 'float32', 'LO_DIFF': 'float32'
|
| 611 |
-
}
|
| 612 |
-
|
| 613 |
-
"""
|
| 614 |
-
# Try to read the CSV file with specified dtypes
|
| 615 |
-
try:
|
| 616 |
-
self.df = pd.read_csv(
|
| 617 |
-
self.input_file,
|
| 618 |
-
usecols=list(dtypes.keys()),
|
| 619 |
-
dtype=dtypes
|
| 620 |
-
)
|
| 621 |
-
except Exception as e:
|
| 622 |
-
logging.warning(f"Initial read_csv failed: {e}. Retrying without parse_dates...")
|
| 623 |
-
|
| 624 |
-
self.df['TRAN_DATE'] = pd.to_datetime(self.df['TRAN_DATE'], errors='coerce')
|
| 625 |
-
self.df.dropna(subset=['TRAN_DATE'], inplace=True)
|
| 626 |
-
|
| 627 |
-
self.first_date = self.df['TRAN_DATE'].min()
|
| 628 |
-
self.last_date = self.df['TRAN_DATE'].max()
|
| 629 |
-
|
| 630 |
-
# Filter data for the last two years
|
| 631 |
-
scale_data = False
|
| 632 |
-
training_days = 365 * 2 # Years of data to consider for scaling
|
| 633 |
-
if scale_data:
|
| 634 |
-
self.scaled_date = self.last_date - timedelta(days=training_days)
|
| 635 |
-
logging.info(f"Processing transaction dates and filtering data from: {self.scaled_date.strftime('%Y-%m-%d')}...")
|
| 636 |
-
mask = self.df['TRAN_DATE'] >= self.scaled_date
|
| 637 |
-
latest_data = self.df[mask].copy()
|
| 638 |
-
self.df = latest_data
|
| 639 |
-
else:
|
| 640 |
-
logging.info(f"Processing transaction dates and filtering data from: {self.first_date.strftime('%Y-%m-%d')}...")
|
| 641 |
-
|
| 642 |
-
self.df = self.df.sort_values(by=['SYMBOL', 'TRAN_DATE']).reset_index(drop=True)
|
| 643 |
-
"""
|
| 644 |
-
|
| 645 |
-
# If extract file already exists, load it to avoid recomputing
|
| 646 |
-
loaded_from_parquet = False
|
| 647 |
-
if self.extract_file.exists():
|
| 648 |
-
logging.info(f"Extract file {self.extract_file} already exists. Loading existing data...")
|
| 649 |
-
try:
|
| 650 |
-
self.df = pd.read_parquet(self.extract_file)
|
| 651 |
-
# Verify required columns exist
|
| 652 |
-
required_cols = ['HIGH_PRICE', 'LOW_PRICE', 'CLOSE_PRICE', 'OPEN_PRICE', 'NET_TRDQTY']
|
| 653 |
-
missing_cols = [col for col in required_cols if col not in self.df.columns]
|
| 654 |
-
if missing_cols:
|
| 655 |
-
logging.warning(f"Parquet file missing required columns: {missing_cols}. Falling back to CSV.")
|
| 656 |
-
self.df = pd.DataFrame()
|
| 657 |
-
else:
|
| 658 |
-
loaded_from_parquet = True
|
| 659 |
-
except Exception as e:
|
| 660 |
-
logging.warning(f"Initial read_parquet failed: {e}. Retrying to load csv...")
|
| 661 |
-
|
| 662 |
-
if not loaded_from_parquet:
|
| 663 |
-
parquet_src = self.data_dir / 'nse_processed_data.parquet'
|
| 664 |
-
if parquet_src.exists():
|
| 665 |
-
logging.info(f"Loading data from Parquet source: {parquet_src}")
|
| 666 |
-
try:
|
| 667 |
-
self.df = pd.read_parquet(parquet_src)
|
| 668 |
-
try:
|
| 669 |
-
self.save_extract_file()
|
| 670 |
-
except Exception as e:
|
| 671 |
-
logging.error(f"Failed to save updated extract file: {e}")
|
| 672 |
-
except Exception as e:
|
| 673 |
-
logging.warning(f"Initial read_parquet from source failed: {e}. Retrying with CSV...")
|
| 674 |
-
|
| 675 |
-
if self.df.empty:
|
| 676 |
-
if self.input_file.exists():
|
| 677 |
-
logging.info(f"Loading data from CSV: {self.input_file}")
|
| 678 |
-
try:
|
| 679 |
-
self.df = pd.read_csv(self.input_file)
|
| 680 |
-
try:
|
| 681 |
-
self.save_extract_file()
|
| 682 |
-
except Exception as e:
|
| 683 |
-
logging.error(f"Failed to save updated extract file: {e}")
|
| 684 |
-
except Exception as e:
|
| 685 |
-
logging.warning(f"Initial read_csv failed: {e}. Exiting...")
|
| 686 |
-
sys.exit(1)
|
| 687 |
-
else:
|
| 688 |
-
logging.error(f"Input file parquet and csv does not exist. Cannot load data exiting...")
|
| 689 |
-
sys.exit(1)
|
| 690 |
-
|
| 691 |
-
self.df['TRAN_DATE'] = pd.to_datetime(self.df['TRAN_DATE'], errors='coerce')
|
| 692 |
-
self.df.dropna(subset=['TRAN_DATE'], inplace=True)
|
| 693 |
-
# Sort on SYMBOL and TRAN_DATE and reset index to ensure contiguous arrays for each symbol group (prevents talib core dumps)
|
| 694 |
-
self.df = self.df.sort_values(by=['SYMBOL', 'TRAN_DATE']).reset_index(drop=True)
|
| 695 |
-
self.first_date = self.df['TRAN_DATE'].min()
|
| 696 |
-
self.last_date = self.df['TRAN_DATE'].max()
|
| 697 |
-
|
| 698 |
-
logging.info("Data loading and preprocessing completed successfully...")
|
| 699 |
-
# Show correlation analysis results
|
| 700 |
-
# self.run_correlation_analysis(self.df)
|
| 701 |
-
# Log the final DataFrame statistics
|
| 702 |
-
logging.info(f"Data loaded with {len(self.df)} rows and {len(self.df.columns)} columns.")
|
| 703 |
-
# Show all the column from dataframe
|
| 704 |
-
logging.info(f"Data columns: {', '.join(self.df.columns)}")
|
| 705 |
-
logging.info(f"Data contains {self.df['SYMBOL'].nunique()} unique symbols.")
|
| 706 |
-
logging.info(f"Data last date: {self.last_date.strftime('%Y-%m-%d')}, Data first date: {self.first_date.strftime('%Y-%m-%d')}")
|
| 707 |
-
logging.info(f"Data contains {self.df['TRAN_DATE'].nunique()} unique transaction dates.")
|
| 708 |
-
# Show the first and lst few rows of the DataFrame for verification
|
| 709 |
-
#logging.info(f"First 5 rows of the DataFrame:\n{self.df.head()}")
|
| 710 |
-
#logging.info(f"Last 5 rows of the DataFrame:\n{self.df.tail()}")
|
| 711 |
-
|
| 712 |
-
gc.collect() # Explicit garbage collection to free up memory
|
| 713 |
-
|
| 714 |
-
except FileNotFoundError:
|
| 715 |
-
logging.error(f"Error: Input file not found at {self.input_file}. Please ensure 'nse_processed_data.csv' exists.")
|
| 716 |
-
sys.exit(1) # Exit if the critical input file is missing
|
| 717 |
-
except Exception as e:
|
| 718 |
-
logging.error(f"Failed to load and preprocess data: {e}")
|
| 719 |
-
sys.exit(1) # Exit on general data processing failure
|
| 720 |
-
|
| 721 |
-
def select_features(self, corr_threshold=0.9999):
|
| 722 |
-
"""
|
| 723 |
-
Selects relevant features by removing highly correlated ones.
|
| 724 |
-
Mutual Information (MI) filtering is not explicitly applied here based on the original code,
|
| 725 |
-
but the function is retained for clarity of purpose.
|
| 726 |
-
"""
|
| 727 |
-
logging.info(f"Running feature selection using correlation. Number of features before selection: {len(self.df.columns)}")
|
| 728 |
-
|
| 729 |
-
numeric_cols = self.df.select_dtypes(include=[np.number]).columns.tolist()
|
| 730 |
-
|
| 731 |
-
clean_df = self.df[numeric_cols].dropna()
|
| 732 |
-
|
| 733 |
-
# Drop highly correlated features based on absolute correlation
|
| 734 |
-
corr_matrix = clean_df.corr().abs()
|
| 735 |
-
# Select upper triangle of correlation matrix to avoid duplicates and self-correlation
|
| 736 |
-
upper_tri = corr_matrix.where(
|
| 737 |
-
pd.DataFrame(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool),
|
| 738 |
-
columns=corr_matrix.columns, index=corr_matrix.index)
|
| 739 |
-
)
|
| 740 |
-
# Find features with correlation greater than threshold
|
| 741 |
-
to_drop_corr = [col for col in upper_tri.columns if any(upper_tri[col] > corr_threshold)]
|
| 742 |
-
reduced_df = clean_df.drop(columns=to_drop_corr)
|
| 743 |
-
|
| 744 |
-
selected_features = reduced_df.columns.tolist()
|
| 745 |
-
|
| 746 |
-
logging.info(f"Selected {len(selected_features)} features after correlation filtering.")
|
| 747 |
-
return selected_features, to_drop_corr
|
| 748 |
-
|
| 749 |
-
# --- SymbolRecommender Class (for model training and tuning) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/models.py
DELETED
|
@@ -1,274 +0,0 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
import logging
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
from datetime import datetime, timedelta
|
| 5 |
-
import json
|
| 6 |
-
import gc
|
| 7 |
-
import warnings
|
| 8 |
-
import numpy as np
|
| 9 |
-
import tensorflow as tf
|
| 10 |
-
from tensorflow.keras.models import Sequential, load_model
|
| 11 |
-
from tensorflow.keras.optimizers import Adam
|
| 12 |
-
from tensorflow.keras.callbacks import LearningRateScheduler, EarlyStopping, ModelCheckpoint
|
| 13 |
-
from tensorflow.keras.layers import Input, LSTM, Bidirectional, Dense, Dropout, BatchNormalization, Conv1D, GRU, Attention, Concatenate
|
| 14 |
-
from tensorflow.keras.models import Model
|
| 15 |
-
from tensorflow.keras.callbacks import ReduceLROnPlateau, Callback
|
| 16 |
-
import tensorflow.keras.backend as K
|
| 17 |
-
import optuna
|
| 18 |
-
from optuna.integration import TFKerasPruningCallback
|
| 19 |
-
from rich.table import Table
|
| 20 |
-
from rich.console import Console
|
| 21 |
-
from rich import box
|
| 22 |
-
import matplotlib.pyplot as plt
|
| 23 |
-
import seaborn as sns
|
| 24 |
-
import threading
|
| 25 |
-
|
| 26 |
-
matplotlib_lock = threading.Lock()
|
| 27 |
-
from sklearn.preprocessing import MinMaxScaler
|
| 28 |
-
from sklearn.utils.class_weight import compute_class_weight
|
| 29 |
-
from sklearn.model_selection import train_test_split
|
| 30 |
-
import time
|
| 31 |
-
import sys
|
| 32 |
-
import os
|
| 33 |
-
import functools
|
| 34 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError
|
| 35 |
-
import threading
|
| 36 |
-
from multiprocessing import Pool
|
| 37 |
-
from itertools import product
|
| 38 |
-
import signal
|
| 39 |
-
import talib
|
| 40 |
-
import fastparquet
|
| 41 |
-
|
| 42 |
-
import torch
|
| 43 |
-
import torch.nn as nn
|
| 44 |
-
import torch.nn.functional as F
|
| 45 |
-
from torch.utils.data import DataLoader, TensorDataset
|
| 46 |
-
from torch.optim import Adam
|
| 47 |
-
from torch.nn import HuberLoss
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class FunctionalAttentionModel(nn.Module):
|
| 51 |
-
def __init__(self, input_dim, params):
|
| 52 |
-
super(FunctionalAttentionModel, self).__init__()
|
| 53 |
-
self.conv1d = nn.Conv1d(in_channels=input_dim, out_channels=64, kernel_size=3, padding=2) # causal padding manually
|
| 54 |
-
self.bn_c1 = nn.BatchNorm1d(64)
|
| 55 |
-
self.dropout_c1 = nn.Dropout(0.2)
|
| 56 |
-
|
| 57 |
-
self.bilstm1 = nn.LSTM(input_size=64, hidden_size=params["lstm_units_1"], batch_first=True,
|
| 58 |
-
bidirectional=True)
|
| 59 |
-
self.bn_lstm1 = nn.BatchNorm1d(2 * params["lstm_units_1"])
|
| 60 |
-
self.dropout_lstm1 = nn.Dropout(params["dropout_1"])
|
| 61 |
-
|
| 62 |
-
self.attention = nn.MultiheadAttention(embed_dim=2 * params["lstm_units_1"],
|
| 63 |
-
num_heads=1,
|
| 64 |
-
batch_first=True)
|
| 65 |
-
|
| 66 |
-
self.bilstm2 = nn.LSTM(input_size=4 * params["lstm_units_1"], hidden_size=params["lstm_units_1"],
|
| 67 |
-
batch_first=True, bidirectional=True)
|
| 68 |
-
self.bn_lstm2 = nn.BatchNorm1d(2 * params["lstm_units_1"])
|
| 69 |
-
self.dropout_lstm2 = nn.Dropout(params["dropout_2"])
|
| 70 |
-
|
| 71 |
-
self.dense1 = nn.Linear(2 * params["lstm_units_1"], params["dense_units_1"])
|
| 72 |
-
self.bn_d1 = nn.BatchNorm1d(params["dense_units_1"])
|
| 73 |
-
self.dropout_d1 = nn.Dropout(params["dropout_3"])
|
| 74 |
-
|
| 75 |
-
self.dense2 = nn.Linear(params["dense_units_1"], params["dense_units_2"])
|
| 76 |
-
self.bn_d2 = nn.BatchNorm1d(params["dense_units_2"])
|
| 77 |
-
self.dropout_d2 = nn.Dropout(params["dropout_4"])
|
| 78 |
-
|
| 79 |
-
self.output = nn.Linear(params["dense_units_2"], 1)
|
| 80 |
-
|
| 81 |
-
def forward(self, x):
|
| 82 |
-
# x: (batch, seq_len, input_dim)
|
| 83 |
-
x = x.transpose(1, 2) # (batch, input_dim, seq_len) for Conv1d
|
| 84 |
-
x = self.conv1d(x)[..., :-2] # causal padding: remove last 2 timesteps
|
| 85 |
-
x = self.bn_c1(x)
|
| 86 |
-
x = self.dropout_c1(x)
|
| 87 |
-
x = x.transpose(1, 2) # back to (batch, seq_len, channels)
|
| 88 |
-
|
| 89 |
-
lstm1_out, _ = self.bilstm1(x)
|
| 90 |
-
lstm1_out_bn = self.bn_lstm1(lstm1_out.transpose(1, 2)).transpose(1, 2)
|
| 91 |
-
lstm1_out_drop = self.dropout_lstm1(lstm1_out_bn)
|
| 92 |
-
|
| 93 |
-
attn_output, _ = self.attention(lstm1_out_drop, lstm1_out_drop, lstm1_out_drop)
|
| 94 |
-
concat = torch.cat([lstm1_out_drop, attn_output], dim=-1)
|
| 95 |
-
|
| 96 |
-
lstm2_out, _ = self.bilstm2(concat)
|
| 97 |
-
lstm2_out_bn = self.bn_lstm2(lstm2_out.transpose(1, 2)).transpose(1, 2)
|
| 98 |
-
lstm2_out = self.dropout_lstm2(lstm2_out_bn)
|
| 99 |
-
|
| 100 |
-
# Take last timestep output
|
| 101 |
-
last_output = lstm2_out[:, -1, :]
|
| 102 |
-
|
| 103 |
-
x = self.dense1(last_output)
|
| 104 |
-
x = self.bn_d1(x)
|
| 105 |
-
x = F.relu(x)
|
| 106 |
-
x = self.dropout_d1(x)
|
| 107 |
-
|
| 108 |
-
x = self.dense2(x)
|
| 109 |
-
x = self.bn_d2(x)
|
| 110 |
-
x = F.relu(x)
|
| 111 |
-
x = self.dropout_d2(x)
|
| 112 |
-
|
| 113 |
-
return self.output(x)
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
class LSTMModel(nn.Module):
|
| 117 |
-
def __init__(self, input_dim, params):
|
| 118 |
-
super(LSTMModel, self).__init__()
|
| 119 |
-
self.lstm_units = params["lstm_units_1"]
|
| 120 |
-
|
| 121 |
-
# Bidirectional LSTM Layer 1
|
| 122 |
-
self.bilstm1 = nn.LSTM(input_size=input_dim,
|
| 123 |
-
hidden_size=self.lstm_units,
|
| 124 |
-
batch_first=True,
|
| 125 |
-
bidirectional=True)
|
| 126 |
-
self.bn_lstm1 = nn.BatchNorm1d(2 * self.lstm_units)
|
| 127 |
-
self.dropout_lstm1 = nn.Dropout(params["dropout_1"])
|
| 128 |
-
|
| 129 |
-
# Bidirectional LSTM Layer 2
|
| 130 |
-
self.bilstm2 = nn.LSTM(input_size=2 * self.lstm_units,
|
| 131 |
-
hidden_size=self.lstm_units,
|
| 132 |
-
batch_first=True,
|
| 133 |
-
bidirectional=True)
|
| 134 |
-
self.bn_lstm2 = nn.BatchNorm1d(2 * self.lstm_units)
|
| 135 |
-
self.dropout_lstm2 = nn.Dropout(params["dropout_2"])
|
| 136 |
-
|
| 137 |
-
# Dense layers
|
| 138 |
-
self.dense1 = nn.Linear(2 * self.lstm_units, params["dense_units_1"])
|
| 139 |
-
self.bn_d1 = nn.BatchNorm1d(params["dense_units_1"])
|
| 140 |
-
self.dropout_d1 = nn.Dropout(params["dropout_3"])
|
| 141 |
-
|
| 142 |
-
self.dense2 = nn.Linear(params["dense_units_1"], params["dense_units_2"])
|
| 143 |
-
self.bn_d2 = nn.BatchNorm1d(params["dense_units_2"])
|
| 144 |
-
self.dropout_d2 = nn.Dropout(params["dropout_4"])
|
| 145 |
-
|
| 146 |
-
# Output layer
|
| 147 |
-
self.output = nn.Linear(params["dense_units_2"], 1)
|
| 148 |
-
|
| 149 |
-
def forward(self, x):
|
| 150 |
-
# x: (batch, seq_len, input_dim)
|
| 151 |
-
lstm1_out, _ = self.bilstm1(x)
|
| 152 |
-
lstm1_out_bn = self.bn_lstm1(lstm1_out.transpose(1, 2)).transpose(1, 2)
|
| 153 |
-
lstm1_out_drop = self.dropout_lstm1(lstm1_out_bn)
|
| 154 |
-
|
| 155 |
-
lstm2_out, _ = self.bilstm2(lstm1_out_drop)
|
| 156 |
-
lstm2_out_bn = self.bn_lstm2(lstm2_out.transpose(1, 2)).transpose(1, 2)
|
| 157 |
-
lstm2_out_drop = self.dropout_lstm2(lstm2_out_bn)
|
| 158 |
-
|
| 159 |
-
# Take last timestep
|
| 160 |
-
last_output = lstm2_out_drop[:, -1, :]
|
| 161 |
-
|
| 162 |
-
x = self.dense1(last_output)
|
| 163 |
-
x = self.bn_d1(x)
|
| 164 |
-
x = F.relu(x)
|
| 165 |
-
x = self.dropout_d1(x)
|
| 166 |
-
|
| 167 |
-
x = self.dense2(x)
|
| 168 |
-
x = self.bn_d2(x)
|
| 169 |
-
x = F.relu(x)
|
| 170 |
-
x = self.dropout_d2(x)
|
| 171 |
-
|
| 172 |
-
return self.output(x)
|
| 173 |
-
|
| 174 |
-
class GRUAttentionModel(nn.Module):
|
| 175 |
-
def __init__(self, input_dim, params):
|
| 176 |
-
super(GRUAttentionModel, self).__init__()
|
| 177 |
-
self.conv1d = nn.Conv1d(in_channels=input_dim, out_channels=64, kernel_size=3, padding=2) # causal padding
|
| 178 |
-
self.bn_c1 = nn.BatchNorm1d(64)
|
| 179 |
-
self.dropout_c1 = nn.Dropout(0.2)
|
| 180 |
-
|
| 181 |
-
self.bi_gru1 = nn.GRU(input_size=64, hidden_size=params["lstm_units_1"],
|
| 182 |
-
batch_first=True, bidirectional=True)
|
| 183 |
-
self.bn_gru1 = nn.BatchNorm1d(2 * params["lstm_units_1"])
|
| 184 |
-
self.dropout_gru1 = nn.Dropout(params["dropout_1"])
|
| 185 |
-
|
| 186 |
-
self.attention = nn.MultiheadAttention(embed_dim=2 * params["lstm_units_1"],
|
| 187 |
-
num_heads=1,
|
| 188 |
-
batch_first=True)
|
| 189 |
-
|
| 190 |
-
self.bi_gru2 = nn.GRU(input_size=4 * params["lstm_units_1"], hidden_size=params["lstm_units_1"],
|
| 191 |
-
batch_first=True, bidirectional=True)
|
| 192 |
-
self.bn_gru2 = nn.BatchNorm1d(2 * params["lstm_units_1"])
|
| 193 |
-
self.dropout_gru2 = nn.Dropout(params["dropout_2"])
|
| 194 |
-
|
| 195 |
-
self.dense1 = nn.Linear(2 * params["lstm_units_1"], params["dense_units_1"])
|
| 196 |
-
self.bn_d1 = nn.BatchNorm1d(params["dense_units_1"])
|
| 197 |
-
self.dropout_d1 = nn.Dropout(params["dropout_3"])
|
| 198 |
-
|
| 199 |
-
self.dense2 = nn.Linear(params["dense_units_1"], params["dense_units_2"])
|
| 200 |
-
self.bn_d2 = nn.BatchNorm1d(params["dense_units_2"])
|
| 201 |
-
self.dropout_d2 = nn.Dropout(params["dropout_4"])
|
| 202 |
-
|
| 203 |
-
self.output = nn.Linear(params["dense_units_2"], 1)
|
| 204 |
-
|
| 205 |
-
def forward(self, x):
|
| 206 |
-
# x: (batch, seq_len, input_dim)
|
| 207 |
-
x = x.transpose(1, 2) # (batch, input_dim, seq_len) for Conv1d
|
| 208 |
-
x = self.conv1d(x)[..., :-2] # causal padding: remove last 2 timesteps
|
| 209 |
-
x = self.bn_c1(x)
|
| 210 |
-
x = self.dropout_c1(x)
|
| 211 |
-
x = x.transpose(1, 2) # back to (batch, seq_len, channels)
|
| 212 |
-
|
| 213 |
-
gru1_out, _ = self.bi_gru1(x)
|
| 214 |
-
gru1_out_bn = self.bn_gru1(gru1_out.transpose(1, 2)).transpose(1, 2)
|
| 215 |
-
gru1_out_drop = self.dropout_gru1(gru1_out_bn)
|
| 216 |
-
|
| 217 |
-
attn_output, _ = self.attention(gru1_out_drop, gru1_out_drop, gru1_out_drop)
|
| 218 |
-
concat = torch.cat([gru1_out_drop, attn_output], dim=-1)
|
| 219 |
-
|
| 220 |
-
gru2_out, _ = self.bi_gru2(concat)
|
| 221 |
-
gru2_out_bn = self.bn_gru2(gru2_out.transpose(1, 2)).transpose(1, 2)
|
| 222 |
-
gru2_out_drop = self.dropout_gru2(gru2_out_bn)
|
| 223 |
-
|
| 224 |
-
last_output = gru2_out_drop[:, -1, :] # final timestep
|
| 225 |
-
|
| 226 |
-
x = self.dense1(last_output)
|
| 227 |
-
x = self.bn_d1(x)
|
| 228 |
-
x = F.relu(x)
|
| 229 |
-
x = self.dropout_d1(x)
|
| 230 |
-
|
| 231 |
-
x = self.dense2(x)
|
| 232 |
-
x = self.bn_d2(x)
|
| 233 |
-
x = F.relu(x)
|
| 234 |
-
x = self.dropout_d2(x)
|
| 235 |
-
|
| 236 |
-
return self.output(x)
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
class HalveOnImprovement(Callback):
|
| 241 |
-
def __init__(self, monitor='val_loss', improve_count=3, reduction_factor=0.75, patience=5, verbose=1, min_lr=1e-6):
|
| 242 |
-
super().__init__()
|
| 243 |
-
self.monitor = monitor
|
| 244 |
-
self.patience = patience
|
| 245 |
-
self.verbose = verbose
|
| 246 |
-
self.min_lr = min_lr
|
| 247 |
-
self.best = float('inf')
|
| 248 |
-
self.epoch_counter = 0
|
| 249 |
-
self.wait = 0 # Tracks if any improvement occurred
|
| 250 |
-
self.improve_count = improve_count
|
| 251 |
-
self.reduction_factor = reduction_factor
|
| 252 |
-
|
| 253 |
-
def on_epoch_end(self, epoch, logs=None):
|
| 254 |
-
current = logs.get(self.monitor)
|
| 255 |
-
if current is None:
|
| 256 |
-
return
|
| 257 |
-
|
| 258 |
-
self.epoch_counter += 1
|
| 259 |
-
|
| 260 |
-
if current < self.best:
|
| 261 |
-
self.best = current
|
| 262 |
-
self.wait += 1 # Mark that improvement occurred
|
| 263 |
-
|
| 264 |
-
if self.epoch_counter >= self.patience:
|
| 265 |
-
if self.wait >= self.improve_count:
|
| 266 |
-
old_lr = float(K.get_value(self.model.optimizer.lr))
|
| 267 |
-
new_lr = max(old_lr * self.reduction_factor, self.min_lr)
|
| 268 |
-
K.set_value(self.model.optimizer.lr, new_lr)
|
| 269 |
-
print(f"\n[HalveOnImprovement] Epoch {epoch}: LR reduced from {old_lr:.6f} to {new_lr:.6f} after {self.epoch_counter} epochs with {self.wait} improvements.")
|
| 270 |
-
# Reset counters
|
| 271 |
-
self.epoch_counter = 0
|
| 272 |
-
self.wait = 0
|
| 273 |
-
|
| 274 |
-
# --- Main Application Logic (Menu Driven) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/recommender.py
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
nsereco/routes.py
DELETED
|
@@ -1,260 +0,0 @@
|
|
| 1 |
-
from flask import Blueprint, render_template, jsonify, request
|
| 2 |
-
import logging
|
| 3 |
-
import gc
|
| 4 |
-
|
| 5 |
-
from .data import StockData
|
| 6 |
-
from .recommender import SymbolRecommender
|
| 7 |
-
from .task_manager import task_manager
|
| 8 |
-
|
| 9 |
-
bp = Blueprint('main', __name__)
|
| 10 |
-
|
| 11 |
-
# Global instances (initialized lazily or at startup)
|
| 12 |
-
_stock_data_manager = None
|
| 13 |
-
_recommender = None
|
| 14 |
-
|
| 15 |
-
def get_managers():
|
| 16 |
-
global _stock_data_manager, _recommender
|
| 17 |
-
if _stock_data_manager is None:
|
| 18 |
-
logging.info("Initializing StockData...")
|
| 19 |
-
_stock_data_manager = StockData()
|
| 20 |
-
|
| 21 |
-
# Check if features need to be created/loaded
|
| 22 |
-
if not SymbolRecommender.DL_FEATURES:
|
| 23 |
-
selected_features, dropped_features = _stock_data_manager.select_features()
|
| 24 |
-
SymbolRecommender.DL_FEATURES = selected_features
|
| 25 |
-
SymbolRecommender.dropped_features = dropped_features
|
| 26 |
-
|
| 27 |
-
expected_features = SymbolRecommender.DL_FEATURES
|
| 28 |
-
if 'NIFTY50' not in _stock_data_manager.df['SYMBOL'].values:
|
| 29 |
-
expected_features = [f for f in expected_features if not f.startswith('NIFTY50')]
|
| 30 |
-
|
| 31 |
-
missing_features = [f for f in expected_features if f not in _stock_data_manager.df.columns]
|
| 32 |
-
if missing_features or 'TARGET_PRICES' not in _stock_data_manager.df.columns:
|
| 33 |
-
logging.info("Features or target prices missing from loaded data. Generating features and targets...")
|
| 34 |
-
_stock_data_manager.create_features()
|
| 35 |
-
_stock_data_manager.create_target()
|
| 36 |
-
try:
|
| 37 |
-
_stock_data_manager.save_extract_file()
|
| 38 |
-
except Exception as e:
|
| 39 |
-
logging.error(f"Failed to save updated extract file: {e}")
|
| 40 |
-
|
| 41 |
-
# Filter DL_FEATURES to only include columns that were successfully generated/loaded
|
| 42 |
-
SymbolRecommender.DL_FEATURES = [f for f in SymbolRecommender.DL_FEATURES if f in _stock_data_manager.df.columns]
|
| 43 |
-
|
| 44 |
-
logging.info("Initializing SymbolRecommender...")
|
| 45 |
-
_recommender = SymbolRecommender(
|
| 46 |
-
recommender_dir=_stock_data_manager.recommender_dir,
|
| 47 |
-
training_tracker_path=_stock_data_manager.data_dir / 'nse_dashboard_recommender.json',
|
| 48 |
-
tuning_parameters_path=_stock_data_manager.data_dir / 'nsereco_parameters.json',
|
| 49 |
-
nifty_profit_tracker_path=_stock_data_manager.data_dir / 'nse_dashboard_nifty_profit.json',
|
| 50 |
-
all_profit_tracker_path=_stock_data_manager.data_dir / 'nse_dashboard_all_profit.json'
|
| 51 |
-
)
|
| 52 |
-
return _stock_data_manager, _recommender
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
@bp.route('/')
|
| 56 |
-
def index():
|
| 57 |
-
return render_template('index.html')
|
| 58 |
-
|
| 59 |
-
@bp.route('/api/status')
|
| 60 |
-
def status():
|
| 61 |
-
return jsonify(task_manager.get_status())
|
| 62 |
-
|
| 63 |
-
@bp.route('/api/initialize', methods=['POST'])
|
| 64 |
-
def initialize():
|
| 65 |
-
success, msg = task_manager.start_task("System Initialization", get_managers)
|
| 66 |
-
return jsonify({"success": success, "message": msg})
|
| 67 |
-
|
| 68 |
-
@bp.route('/api/stop', methods=['POST'])
|
| 69 |
-
def stop():
|
| 70 |
-
success, msg = task_manager.stop_task()
|
| 71 |
-
return jsonify({"success": success, "message": msg})
|
| 72 |
-
|
| 73 |
-
@bp.route('/api/kill', methods=['POST'])
|
| 74 |
-
def kill():
|
| 75 |
-
# Hard kill the backend process
|
| 76 |
-
import threading
|
| 77 |
-
import os
|
| 78 |
-
def shutdown():
|
| 79 |
-
import time
|
| 80 |
-
import logging
|
| 81 |
-
import os
|
| 82 |
-
import signal
|
| 83 |
-
time.sleep(1.5)
|
| 84 |
-
logging.warning("Graceful shutdown message due to UI kill switch")
|
| 85 |
-
os.kill(os.getpid(), signal.SIGINT)
|
| 86 |
-
threading.Thread(target=shutdown, daemon=True).start()
|
| 87 |
-
return jsonify({"success": True, "message": "Application shutting down."})
|
| 88 |
-
|
| 89 |
-
import os
|
| 90 |
-
from flask import send_from_directory
|
| 91 |
-
|
| 92 |
-
@bp.route('/api/graphs')
|
| 93 |
-
def list_graphs():
|
| 94 |
-
sm, _ = get_managers()
|
| 95 |
-
if not sm or not sm.recommender_dir:
|
| 96 |
-
return jsonify([])
|
| 97 |
-
recommender_dir = sm.recommender_dir
|
| 98 |
-
graphs = []
|
| 99 |
-
if os.path.exists(recommender_dir):
|
| 100 |
-
graphs = [f for f in os.listdir(recommender_dir) if f.startswith('nsereco_backtest_') and f.endswith('.png')]
|
| 101 |
-
return jsonify(sorted(graphs))
|
| 102 |
-
|
| 103 |
-
@bp.route('/api/graphs/<filename>')
|
| 104 |
-
def serve_graph(filename):
|
| 105 |
-
sm, _ = get_managers()
|
| 106 |
-
recommender_dir = sm.recommender_dir
|
| 107 |
-
return send_from_directory(recommender_dir, filename)
|
| 108 |
-
|
| 109 |
-
@bp.route('/api/tune_selected', methods=['POST'])
|
| 110 |
-
def tune_selected():
|
| 111 |
-
data = request.json
|
| 112 |
-
num_stocks = int(data.get('num_stocks', 20))
|
| 113 |
-
worker_threads = int(data.get('worker_threads', 8))
|
| 114 |
-
|
| 115 |
-
def task():
|
| 116 |
-
from nsereco.agent_workflow import run_agent_workflow
|
| 117 |
-
run_agent_workflow('tune', num_stocks, worker_threads)
|
| 118 |
-
import gc
|
| 119 |
-
gc.collect()
|
| 120 |
-
|
| 121 |
-
success, msg = task_manager.start_task(f"Agentic Hyperparameter tuning ({num_stocks} stocks, {worker_threads} threads)", task)
|
| 122 |
-
return jsonify({"success": success, "message": msg})
|
| 123 |
-
|
| 124 |
-
@bp.route('/api/train_all', methods=['POST'])
|
| 125 |
-
def train_all():
|
| 126 |
-
data = request.json or {}
|
| 127 |
-
num_stocks = int(data.get('num_stocks', 20))
|
| 128 |
-
worker_threads = int(data.get('worker_threads', 8))
|
| 129 |
-
|
| 130 |
-
def task():
|
| 131 |
-
from nsereco.agent_workflow import run_agent_workflow
|
| 132 |
-
run_agent_workflow('train', num_stocks, worker_threads)
|
| 133 |
-
import gc
|
| 134 |
-
gc.collect()
|
| 135 |
-
|
| 136 |
-
success, msg = task_manager.start_task(f"Agentic Model training ({num_stocks} stocks, {worker_threads} threads)", task)
|
| 137 |
-
return jsonify({"success": success, "message": msg})
|
| 138 |
-
|
| 139 |
-
@bp.route('/api/optuna_finder', methods=['POST'])
|
| 140 |
-
def optuna_finder():
|
| 141 |
-
data = request.json
|
| 142 |
-
num_trials = int(data.get('num_trials', 30))
|
| 143 |
-
num_symbols = int(data.get('num_symbols', 1))
|
| 144 |
-
|
| 145 |
-
def task():
|
| 146 |
-
sm, rec = get_managers()
|
| 147 |
-
rec.run_optuna_tuning(sm, num_trials=num_trials, num_symbols=num_symbols)
|
| 148 |
-
gc.collect()
|
| 149 |
-
|
| 150 |
-
success, msg = task_manager.start_task("Optuna hyperparameters finder", task)
|
| 151 |
-
return jsonify({"success": success, "message": msg})
|
| 152 |
-
|
| 153 |
-
@bp.route('/api/analyze', methods=['POST'])
|
| 154 |
-
def analyze():
|
| 155 |
-
data = request.json
|
| 156 |
-
symbol = data.get('symbol', 'SBIN')
|
| 157 |
-
|
| 158 |
-
def task():
|
| 159 |
-
sm, rec = get_managers()
|
| 160 |
-
rec.analyze_tuner_data(symbol=symbol)
|
| 161 |
-
|
| 162 |
-
success, msg = task_manager.start_task(f"Tuner data analysis for {symbol}", task)
|
| 163 |
-
return jsonify({"success": success, "message": msg})
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
@bp.route('/api/metrics')
|
| 167 |
-
def get_metrics():
|
| 168 |
-
import json
|
| 169 |
-
import re
|
| 170 |
-
import os
|
| 171 |
-
from pathlib import Path
|
| 172 |
-
|
| 173 |
-
# 1. Total symbols
|
| 174 |
-
global _stock_data_manager
|
| 175 |
-
total_symbols_list = []
|
| 176 |
-
if _stock_data_manager is not None and _stock_data_manager.df is not None:
|
| 177 |
-
total_symbols_list = sorted(_stock_data_manager.df['SYMBOL'].dropna().unique().astype(str).tolist())
|
| 178 |
-
else:
|
| 179 |
-
try:
|
| 180 |
-
data_dir = Path("/source/python/code/nsedata/analysis/processed")
|
| 181 |
-
symbol_list_path = data_dir / 'nse_dashboard_symbollist.json'
|
| 182 |
-
if symbol_list_path.exists():
|
| 183 |
-
with open(symbol_list_path, 'r') as f:
|
| 184 |
-
symbol_dict = json.load(f)
|
| 185 |
-
total_symbols_list = sorted(list(symbol_dict.keys()))
|
| 186 |
-
except Exception:
|
| 187 |
-
pass
|
| 188 |
-
|
| 189 |
-
# 2. Tuned symbols
|
| 190 |
-
tuned_symbols = set()
|
| 191 |
-
for d in ["recommender", "recommender_torch"]:
|
| 192 |
-
path = Path("/source/python/code/nsedata/analysis/processed") / d
|
| 193 |
-
if path.exists():
|
| 194 |
-
for f in os.listdir(path):
|
| 195 |
-
if f.startswith('nsereco_tuner_') and f.endswith('.json'):
|
| 196 |
-
sym = f[len('nsereco_tuner_'):-len('.json')]
|
| 197 |
-
tuned_symbols.add(sym)
|
| 198 |
-
tuned_symbols_list = sorted(list(tuned_symbols))
|
| 199 |
-
|
| 200 |
-
# 3. Trained symbols
|
| 201 |
-
trained_symbols_list = []
|
| 202 |
-
global _recommender
|
| 203 |
-
if _recommender is not None:
|
| 204 |
-
trained_symbols_list = [sym for sym, val in _recommender.training_tracker_data.items() if val.get("training", False)]
|
| 205 |
-
else:
|
| 206 |
-
try:
|
| 207 |
-
recommender_json_path = Path("/source/python/code/nsedata/analysis/processed/nse_dashboard_recommender.json")
|
| 208 |
-
if recommender_json_path.exists():
|
| 209 |
-
with open(recommender_json_path, 'r') as f:
|
| 210 |
-
rec_data = json.load(f) or {}
|
| 211 |
-
trained_symbols_list = [sym for sym, val in rec_data.items() if isinstance(val, dict) and val.get("training", False)]
|
| 212 |
-
except Exception:
|
| 213 |
-
pass
|
| 214 |
-
trained_symbols_list = sorted(trained_symbols_list)
|
| 215 |
-
|
| 216 |
-
# 4. Backtest graphs symbols
|
| 217 |
-
backtest_symbols = set()
|
| 218 |
-
for d in ["recommender", "recommender_torch"]:
|
| 219 |
-
path = Path("/source/python/code/nsedata/analysis/processed") / d
|
| 220 |
-
if path.exists():
|
| 221 |
-
for f in os.listdir(path):
|
| 222 |
-
if f.startswith('nsereco_backtest_') and f.endswith('.png'):
|
| 223 |
-
name = f[len('nsereco_backtest_'):-len('.png')]
|
| 224 |
-
match = re.match(r'^([A-Za-z0-9\-&]+)(?:_v\d+)?$', name)
|
| 225 |
-
if match:
|
| 226 |
-
backtest_symbols.add(match.group(1))
|
| 227 |
-
else:
|
| 228 |
-
backtest_symbols.add(name.split('_')[0])
|
| 229 |
-
backtest_symbols_list = sorted(list(backtest_symbols))
|
| 230 |
-
|
| 231 |
-
# 5. Tuning / Training in progress
|
| 232 |
-
status_data = task_manager.get_status()
|
| 233 |
-
is_running = status_data["is_running"]
|
| 234 |
-
status_str = status_data["status"].lower()
|
| 235 |
-
tuning_in_progress = is_running and ("tune" in status_str or "optuna" in status_str or "finder" in status_str)
|
| 236 |
-
training_in_progress = is_running and ("train" in status_str)
|
| 237 |
-
|
| 238 |
-
return jsonify({
|
| 239 |
-
"total_symbols": {
|
| 240 |
-
"count": len(total_symbols_list),
|
| 241 |
-
"symbols": total_symbols_list
|
| 242 |
-
},
|
| 243 |
-
"tuned_symbols": {
|
| 244 |
-
"count": len(tuned_symbols_list),
|
| 245 |
-
"symbols": tuned_symbols_list
|
| 246 |
-
},
|
| 247 |
-
"trained_symbols": {
|
| 248 |
-
"count": len(trained_symbols_list),
|
| 249 |
-
"symbols": trained_symbols_list
|
| 250 |
-
},
|
| 251 |
-
"backtest_graphs": {
|
| 252 |
-
"count": len(backtest_symbols_list),
|
| 253 |
-
"symbols": backtest_symbols_list
|
| 254 |
-
},
|
| 255 |
-
"tuning_in_progress": tuning_in_progress,
|
| 256 |
-
"training_in_progress": training_in_progress,
|
| 257 |
-
"completed_count": status_data["completed_count"],
|
| 258 |
-
"total_count": status_data["total_count"]
|
| 259 |
-
})
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/static/css/style.css
DELETED
|
@@ -1,377 +0,0 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
--bg-dark: #0f172a;
|
| 3 |
-
--card-bg: rgba(30, 41, 59, 0.7);
|
| 4 |
-
--primary-color: #3b82f6;
|
| 5 |
-
--primary-hover: #2563eb;
|
| 6 |
-
--text-main: #f8fafc;
|
| 7 |
-
--text-muted: #94a3b8;
|
| 8 |
-
--success: #10b981;
|
| 9 |
-
--warning: #f59e0b;
|
| 10 |
-
--danger: #ef4444;
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
* {
|
| 14 |
-
box-sizing: border-box;
|
| 15 |
-
margin: 0;
|
| 16 |
-
padding: 0;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
body {
|
| 20 |
-
font-family: 'Inter', sans-serif;
|
| 21 |
-
background-color: var(--bg-dark);
|
| 22 |
-
color: var(--text-main);
|
| 23 |
-
line-height: 1.6;
|
| 24 |
-
min-height: 100vh;
|
| 25 |
-
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
.app-container {
|
| 29 |
-
max-width: 1400px;
|
| 30 |
-
margin: 0 auto;
|
| 31 |
-
padding: 2rem;
|
| 32 |
-
display: flex;
|
| 33 |
-
flex-direction: column;
|
| 34 |
-
gap: 2rem;
|
| 35 |
-
height: 100vh;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
.app-header {
|
| 39 |
-
display: flex;
|
| 40 |
-
justify-content: space-between;
|
| 41 |
-
align-items: center;
|
| 42 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 43 |
-
padding-bottom: 1rem;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
.app-header h1 {
|
| 47 |
-
font-weight: 600;
|
| 48 |
-
letter-spacing: -0.5px;
|
| 49 |
-
background: -webkit-linear-gradient(#60a5fa, #a78bfa);
|
| 50 |
-
-webkit-background-clip: text;
|
| 51 |
-
-webkit-text-fill-color: transparent;
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
.status-indicator {
|
| 55 |
-
display: flex;
|
| 56 |
-
align-items: center;
|
| 57 |
-
gap: 0.5rem;
|
| 58 |
-
font-weight: 600;
|
| 59 |
-
background: rgba(0, 0, 0, 0.3);
|
| 60 |
-
padding: 0.5rem 1rem;
|
| 61 |
-
border-radius: 20px;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
.status-dot {
|
| 65 |
-
width: 10px;
|
| 66 |
-
height: 10px;
|
| 67 |
-
border-radius: 50%;
|
| 68 |
-
background-color: var(--success);
|
| 69 |
-
box-shadow: 0 0 8px var(--success);
|
| 70 |
-
transition: background-color 0.3s ease;
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
.status-dot.running {
|
| 74 |
-
background-color: var(--warning);
|
| 75 |
-
box-shadow: 0 0 8px var(--warning);
|
| 76 |
-
animation: pulse 1.5s infinite;
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
@keyframes pulse {
|
| 80 |
-
0% { transform: scale(0.95); opacity: 0.7; }
|
| 81 |
-
50% { transform: scale(1.1); opacity: 1; }
|
| 82 |
-
100% { transform: scale(0.95); opacity: 0.7; }
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
.dashboard-grid {
|
| 86 |
-
display: grid;
|
| 87 |
-
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 88 |
-
gap: 1.5rem;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
.glassmorphism {
|
| 92 |
-
background: var(--card-bg);
|
| 93 |
-
backdrop-filter: blur(12px);
|
| 94 |
-
-webkit-backdrop-filter: blur(12px);
|
| 95 |
-
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 96 |
-
border-radius: 12px;
|
| 97 |
-
padding: 1.5rem;
|
| 98 |
-
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
| 99 |
-
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
.glassmorphism:hover {
|
| 103 |
-
transform: translateY(-2px);
|
| 104 |
-
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
.card h2 {
|
| 108 |
-
font-size: 1.25rem;
|
| 109 |
-
margin-bottom: 0.75rem;
|
| 110 |
-
color: #e2e8f0;
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
.card p {
|
| 114 |
-
color: var(--text-muted);
|
| 115 |
-
font-size: 0.9rem;
|
| 116 |
-
margin-bottom: 1.5rem;
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
.form-group {
|
| 120 |
-
margin-bottom: 1rem;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
.form-group label {
|
| 124 |
-
display: block;
|
| 125 |
-
font-size: 0.85rem;
|
| 126 |
-
color: var(--text-muted);
|
| 127 |
-
margin-bottom: 0.25rem;
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
.form-group input {
|
| 131 |
-
width: 100%;
|
| 132 |
-
background: rgba(0, 0, 0, 0.2);
|
| 133 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 134 |
-
color: var(--text-main);
|
| 135 |
-
padding: 0.5rem 0.75rem;
|
| 136 |
-
border-radius: 6px;
|
| 137 |
-
font-family: inherit;
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
.form-group input:focus {
|
| 141 |
-
outline: none;
|
| 142 |
-
border-color: var(--primary-color);
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
.btn {
|
| 146 |
-
width: 100%;
|
| 147 |
-
padding: 0.75rem;
|
| 148 |
-
border: none;
|
| 149 |
-
border-radius: 6px;
|
| 150 |
-
font-family: inherit;
|
| 151 |
-
font-weight: 600;
|
| 152 |
-
cursor: pointer;
|
| 153 |
-
transition: all 0.2s ease;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
.btn.primary {
|
| 157 |
-
background-color: var(--primary-color);
|
| 158 |
-
color: white;
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
.btn.primary:hover {
|
| 162 |
-
background-color: var(--primary-hover);
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
.btn:disabled {
|
| 166 |
-
opacity: 0.5;
|
| 167 |
-
cursor: not-allowed;
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
.console-section {
|
| 171 |
-
flex-grow: 1;
|
| 172 |
-
display: flex;
|
| 173 |
-
flex-direction: column;
|
| 174 |
-
min-height: 300px;
|
| 175 |
-
background: rgba(0, 0, 0, 0.5);
|
| 176 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 177 |
-
border-radius: 12px;
|
| 178 |
-
overflow: hidden;
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
.console-header {
|
| 182 |
-
background: rgba(255, 255, 255, 0.05);
|
| 183 |
-
padding: 0.75rem 1.5rem;
|
| 184 |
-
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
.console-header h3 {
|
| 188 |
-
font-size: 1rem;
|
| 189 |
-
font-weight: 600;
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
.console-window {
|
| 193 |
-
padding: 1rem 1.5rem;
|
| 194 |
-
overflow-y: auto;
|
| 195 |
-
font-family: 'Consolas', 'Courier New', monospace;
|
| 196 |
-
font-size: 0.85rem;
|
| 197 |
-
color: #a3e635; /* Terminal green */
|
| 198 |
-
line-height: 1.5;
|
| 199 |
-
flex-grow: 1;
|
| 200 |
-
}
|
| 201 |
-
|
| 202 |
-
.console-line {
|
| 203 |
-
margin-bottom: 2px;
|
| 204 |
-
word-wrap: break-word;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
.console-actions {
|
| 208 |
-
display: flex;
|
| 209 |
-
gap: 0.5rem;
|
| 210 |
-
}
|
| 211 |
-
|
| 212 |
-
.btn.secondary {
|
| 213 |
-
background-color: #64748b;
|
| 214 |
-
color: white;
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
.btn.secondary:hover {
|
| 218 |
-
background-color: #475569;
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
.btn.warning {
|
| 222 |
-
background-color: var(--warning);
|
| 223 |
-
color: white;
|
| 224 |
-
}
|
| 225 |
-
|
| 226 |
-
.btn.warning:hover {
|
| 227 |
-
background-color: #d97706;
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
-
.btn.danger {
|
| 231 |
-
background-color: var(--danger);
|
| 232 |
-
color: white;
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
.btn.danger:hover {
|
| 236 |
-
background-color: #b91c1c;
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
/* Modal CSS */
|
| 240 |
-
.modal {
|
| 241 |
-
display: none;
|
| 242 |
-
position: fixed;
|
| 243 |
-
z-index: 1000;
|
| 244 |
-
left: 0;
|
| 245 |
-
top: 0;
|
| 246 |
-
width: 100%;
|
| 247 |
-
height: 100%;
|
| 248 |
-
overflow: auto;
|
| 249 |
-
background-color: rgba(0,0,0,0.6);
|
| 250 |
-
backdrop-filter: blur(5px);
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
.modal-content {
|
| 254 |
-
background-color: var(--bg-dark);
|
| 255 |
-
margin: 5% auto;
|
| 256 |
-
padding: 20px;
|
| 257 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 258 |
-
width: 80%;
|
| 259 |
-
max-width: 900px;
|
| 260 |
-
border-radius: 12px;
|
| 261 |
-
position: relative;
|
| 262 |
-
}
|
| 263 |
-
|
| 264 |
-
.close-btn {
|
| 265 |
-
color: var(--text-muted);
|
| 266 |
-
float: right;
|
| 267 |
-
font-size: 28px;
|
| 268 |
-
font-weight: bold;
|
| 269 |
-
cursor: pointer;
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
.close-btn:hover,
|
| 273 |
-
.close-btn:focus {
|
| 274 |
-
color: white;
|
| 275 |
-
text-decoration: none;
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
select#graph-select {
|
| 279 |
-
width: 100%;
|
| 280 |
-
background: rgba(0, 0, 0, 0.2);
|
| 281 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 282 |
-
color: var(--text-main);
|
| 283 |
-
padding: 0.5rem 0.75rem;
|
| 284 |
-
border-radius: 6px;
|
| 285 |
-
font-family: inherit;
|
| 286 |
-
margin-bottom: 1rem;
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
/* Metrics Summary Grid and Cards */
|
| 290 |
-
.metrics-grid {
|
| 291 |
-
display: grid;
|
| 292 |
-
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 293 |
-
gap: 1rem;
|
| 294 |
-
margin-bottom: 1rem;
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
.metric-card {
|
| 298 |
-
display: flex;
|
| 299 |
-
flex-direction: column;
|
| 300 |
-
align-items: center;
|
| 301 |
-
justify-content: center;
|
| 302 |
-
padding: 1rem;
|
| 303 |
-
text-align: center;
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
.metric-label {
|
| 307 |
-
font-size: 0.8rem;
|
| 308 |
-
color: var(--text-muted);
|
| 309 |
-
font-weight: 600;
|
| 310 |
-
text-transform: uppercase;
|
| 311 |
-
letter-spacing: 0.5px;
|
| 312 |
-
margin-bottom: 0.5rem;
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
.metric-value {
|
| 316 |
-
font-size: 1.8rem;
|
| 317 |
-
font-weight: 600;
|
| 318 |
-
color: #3b82f6;
|
| 319 |
-
background: -webkit-linear-gradient(#60a5fa, #3b82f6);
|
| 320 |
-
-webkit-background-clip: text;
|
| 321 |
-
-webkit-text-fill-color: transparent;
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
-
.metric-value.clickable {
|
| 325 |
-
cursor: pointer;
|
| 326 |
-
text-decoration: underline dotted;
|
| 327 |
-
text-underline-offset: 4px;
|
| 328 |
-
transition: transform 0.2s ease, opacity 0.2s ease;
|
| 329 |
-
}
|
| 330 |
-
|
| 331 |
-
.metric-value.clickable:hover {
|
| 332 |
-
transform: scale(1.05);
|
| 333 |
-
opacity: 0.85;
|
| 334 |
-
}
|
| 335 |
-
|
| 336 |
-
.metric-value.active-status {
|
| 337 |
-
color: var(--warning);
|
| 338 |
-
background: -webkit-linear-gradient(#fcd34d, #f59e0b);
|
| 339 |
-
-webkit-background-clip: text;
|
| 340 |
-
-webkit-text-fill-color: transparent;
|
| 341 |
-
animation: statusPulse 2s infinite;
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
@keyframes statusPulse {
|
| 345 |
-
0% { opacity: 0.7; }
|
| 346 |
-
50% { opacity: 1; }
|
| 347 |
-
100% { opacity: 0.7; }
|
| 348 |
-
}
|
| 349 |
-
|
| 350 |
-
/* Symbols Grid in Modal */
|
| 351 |
-
.symbols-list-grid {
|
| 352 |
-
display: grid;
|
| 353 |
-
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
| 354 |
-
gap: 0.75rem;
|
| 355 |
-
margin-top: 1.5rem;
|
| 356 |
-
max-height: 400px;
|
| 357 |
-
overflow-y: auto;
|
| 358 |
-
padding-right: 0.5rem;
|
| 359 |
-
}
|
| 360 |
-
|
| 361 |
-
.symbol-badge {
|
| 362 |
-
background: rgba(255, 255, 255, 0.05);
|
| 363 |
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 364 |
-
border-radius: 6px;
|
| 365 |
-
padding: 0.35rem 0.5rem;
|
| 366 |
-
text-align: center;
|
| 367 |
-
font-family: monospace;
|
| 368 |
-
font-size: 0.85rem;
|
| 369 |
-
color: var(--text-main);
|
| 370 |
-
transition: background 0.2s ease, border-color 0.2s ease;
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
.symbol-badge:hover {
|
| 374 |
-
background: rgba(59, 130, 246, 0.2);
|
| 375 |
-
border-color: var(--primary-color);
|
| 376 |
-
}
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/static/js/app.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
| 1 |
-
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
-
const statusText = document.getElementById('system-status');
|
| 3 |
-
const statusDot = document.querySelector('.status-dot');
|
| 4 |
-
const consoleOutput = document.getElementById('console-output');
|
| 5 |
-
|
| 6 |
-
let isTaskRunning = false;
|
| 7 |
-
let autoScroll = true;
|
| 8 |
-
|
| 9 |
-
// Detect if user scrolls up to disable autoscroll
|
| 10 |
-
consoleOutput.addEventListener('scroll', () => {
|
| 11 |
-
const isAtBottom = consoleOutput.scrollHeight - consoleOutput.scrollTop <= consoleOutput.clientHeight + 10;
|
| 12 |
-
autoScroll = isAtBottom;
|
| 13 |
-
});
|
| 14 |
-
|
| 15 |
-
function setButtonsDisabled(disabled) {
|
| 16 |
-
document.querySelectorAll('.btn:not(#btn-stop):not(#btn-view-graphs):not(#btn-kill)').forEach(btn => {
|
| 17 |
-
btn.disabled = disabled;
|
| 18 |
-
});
|
| 19 |
-
document.getElementById('btn-stop').disabled = !disabled;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function appendLogs(logs) {
|
| 23 |
-
if (!logs || logs.length === 0) return;
|
| 24 |
-
|
| 25 |
-
// We only append new logs in a real app, but for simplicity we'll just rewrite it if it's small,
|
| 26 |
-
// or since task_manager clears it on start, we just set innerHTML.
|
| 27 |
-
consoleOutput.innerHTML = logs.map(line => `<div class="console-line">${line}</div>`).join('');
|
| 28 |
-
|
| 29 |
-
if (autoScroll) {
|
| 30 |
-
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
| 31 |
-
}
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
async function checkStatus() {
|
| 35 |
-
try {
|
| 36 |
-
const res = await fetch('/api/status');
|
| 37 |
-
const data = await res.json();
|
| 38 |
-
|
| 39 |
-
isTaskRunning = data.is_running;
|
| 40 |
-
statusText.textContent = data.status;
|
| 41 |
-
|
| 42 |
-
if (isTaskRunning) {
|
| 43 |
-
statusDot.classList.add('running');
|
| 44 |
-
setButtonsDisabled(true);
|
| 45 |
-
} else {
|
| 46 |
-
statusDot.classList.remove('running');
|
| 47 |
-
setButtonsDisabled(false);
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
appendLogs(data.logs);
|
| 51 |
-
} catch (err) {
|
| 52 |
-
console.error('Error fetching status:', err);
|
| 53 |
-
statusText.textContent = 'Connection Error';
|
| 54 |
-
statusDot.classList.remove('running');
|
| 55 |
-
}
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
// Poll status every 2 seconds
|
| 59 |
-
setInterval(checkStatus, 2000);
|
| 60 |
-
checkStatus();
|
| 61 |
-
|
| 62 |
-
async function postAction(endpoint, payload = {}) {
|
| 63 |
-
if (isTaskRunning) return;
|
| 64 |
-
|
| 65 |
-
try {
|
| 66 |
-
const res = await fetch(endpoint, {
|
| 67 |
-
method: 'POST',
|
| 68 |
-
headers: { 'Content-Type': 'application/json' },
|
| 69 |
-
body: JSON.stringify(payload)
|
| 70 |
-
});
|
| 71 |
-
const data = await res.json();
|
| 72 |
-
if (!data.success) {
|
| 73 |
-
alert(data.message);
|
| 74 |
-
} else {
|
| 75 |
-
checkStatus();
|
| 76 |
-
}
|
| 77 |
-
} catch (err) {
|
| 78 |
-
alert('Failed to start task: ' + err.message);
|
| 79 |
-
}
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
document.getElementById('btn-init').addEventListener('click', () => {
|
| 83 |
-
postAction('/api/initialize');
|
| 84 |
-
});
|
| 85 |
-
|
| 86 |
-
document.getElementById('btn-tune').addEventListener('click', () => {
|
| 87 |
-
const numStocks = parseInt(document.getElementById('tune-stocks').value, 10);
|
| 88 |
-
const numThreads = parseInt(document.getElementById('tune-threads').value, 10);
|
| 89 |
-
postAction('/api/tune_selected', { num_stocks: numStocks, worker_threads: numThreads });
|
| 90 |
-
});
|
| 91 |
-
|
| 92 |
-
document.getElementById('btn-train').addEventListener('click', () => {
|
| 93 |
-
const numStocks = parseInt(document.getElementById('train-stocks').value, 10);
|
| 94 |
-
const numThreads = parseInt(document.getElementById('train-threads').value, 10);
|
| 95 |
-
postAction('/api/train_all', { num_stocks: numStocks, worker_threads: numThreads });
|
| 96 |
-
});
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
document.getElementById('btn-stop').addEventListener('click', async () => {
|
| 100 |
-
try {
|
| 101 |
-
const res = await fetch('/api/stop', { method: 'POST' });
|
| 102 |
-
const data = await res.json();
|
| 103 |
-
alert(data.message);
|
| 104 |
-
} catch (err) {
|
| 105 |
-
console.error(err);
|
| 106 |
-
}
|
| 107 |
-
});
|
| 108 |
-
|
| 109 |
-
document.getElementById('btn-kill').addEventListener('click', () => {
|
| 110 |
-
if (confirm("Are you sure you want to completely shut down the application?")) {
|
| 111 |
-
fetch('/api/kill', { method: 'POST' }).catch(err => console.log('Shutdown connection drop:', err));
|
| 112 |
-
alert("Application shutdown initiated.");
|
| 113 |
-
document.body.innerHTML = "<div style='text-align: center; margin-top: 100px;'><h1>Application Terminated</h1><p>You can close this window.</p></div>";
|
| 114 |
-
}
|
| 115 |
-
});
|
| 116 |
-
|
| 117 |
-
// Modal Logic
|
| 118 |
-
const modal = document.getElementById('graph-modal');
|
| 119 |
-
const closeBtn = document.querySelector('.close-btn');
|
| 120 |
-
const selectGraph = document.getElementById('graph-select');
|
| 121 |
-
const graphImg = document.getElementById('graph-img');
|
| 122 |
-
|
| 123 |
-
document.getElementById('btn-view-graphs').addEventListener('click', async () => {
|
| 124 |
-
modal.style.display = 'block';
|
| 125 |
-
try {
|
| 126 |
-
const res = await fetch('/api/graphs');
|
| 127 |
-
const graphs = await res.json();
|
| 128 |
-
selectGraph.innerHTML = '<option value="">Select a graph...</option>';
|
| 129 |
-
if (graphs.length === 0) {
|
| 130 |
-
selectGraph.innerHTML = '<option value="">No graphs available</option>';
|
| 131 |
-
} else {
|
| 132 |
-
graphs.forEach(g => {
|
| 133 |
-
const option = document.createElement('option');
|
| 134 |
-
option.value = g;
|
| 135 |
-
option.textContent = g;
|
| 136 |
-
selectGraph.appendChild(option);
|
| 137 |
-
});
|
| 138 |
-
}
|
| 139 |
-
graphImg.style.display = 'none';
|
| 140 |
-
} catch (err) {
|
| 141 |
-
console.error(err);
|
| 142 |
-
}
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
closeBtn.addEventListener('click', () => {
|
| 146 |
-
modal.style.display = 'none';
|
| 147 |
-
});
|
| 148 |
-
|
| 149 |
-
window.addEventListener('click', (e) => {
|
| 150 |
-
if (e.target === modal) {
|
| 151 |
-
modal.style.display = 'none';
|
| 152 |
-
}
|
| 153 |
-
});
|
| 154 |
-
|
| 155 |
-
selectGraph.addEventListener('change', (e) => {
|
| 156 |
-
const filename = e.target.value;
|
| 157 |
-
if (filename) {
|
| 158 |
-
// Add a timestamp query param to bypass browser cache
|
| 159 |
-
graphImg.src = `/api/graphs/${filename}?t=${new Date().getTime()}`;
|
| 160 |
-
graphImg.style.display = 'block';
|
| 161 |
-
} else {
|
| 162 |
-
graphImg.style.display = 'none';
|
| 163 |
-
}
|
| 164 |
-
});
|
| 165 |
-
|
| 166 |
-
// Metrics and Modal management
|
| 167 |
-
let currentMetrics = {
|
| 168 |
-
total_symbols: { count: 0, symbols: [] },
|
| 169 |
-
tuned_symbols: { count: 0, symbols: [] },
|
| 170 |
-
trained_symbols: { count: 0, symbols: [] },
|
| 171 |
-
backtest_graphs: { count: 0, symbols: [] }
|
| 172 |
-
};
|
| 173 |
-
|
| 174 |
-
async function fetchMetrics() {
|
| 175 |
-
try {
|
| 176 |
-
const res = await fetch('/api/metrics');
|
| 177 |
-
const data = await res.json();
|
| 178 |
-
currentMetrics = data;
|
| 179 |
-
|
| 180 |
-
// Update UI elements
|
| 181 |
-
document.getElementById('metric-total-symbols').textContent = data.total_symbols.count;
|
| 182 |
-
document.getElementById('metric-tuned-symbols').textContent = data.tuned_symbols.count;
|
| 183 |
-
document.getElementById('metric-trained-symbols').textContent = data.trained_symbols.count;
|
| 184 |
-
document.getElementById('metric-backtest-graphs').textContent = data.backtest_graphs.count;
|
| 185 |
-
|
| 186 |
-
// Tuning status
|
| 187 |
-
const tuningStatusEl = document.getElementById('metric-tuning-status');
|
| 188 |
-
if (data.tuning_in_progress) {
|
| 189 |
-
tuningStatusEl.textContent = `Running (${data.completed_count}/${data.total_count})`;
|
| 190 |
-
tuningStatusEl.classList.add('active-status');
|
| 191 |
-
} else {
|
| 192 |
-
tuningStatusEl.textContent = 'Idle';
|
| 193 |
-
tuningStatusEl.classList.remove('active-status');
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
// Training status
|
| 197 |
-
const trainingStatusEl = document.getElementById('metric-training-status');
|
| 198 |
-
if (data.training_in_progress) {
|
| 199 |
-
trainingStatusEl.textContent = `Running (${data.completed_count}/${data.total_count})`;
|
| 200 |
-
trainingStatusEl.classList.add('active-status');
|
| 201 |
-
} else {
|
| 202 |
-
trainingStatusEl.textContent = 'Idle';
|
| 203 |
-
trainingStatusEl.classList.remove('active-status');
|
| 204 |
-
}
|
| 205 |
-
} catch (err) {
|
| 206 |
-
console.error('Error fetching metrics:', err);
|
| 207 |
-
}
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
// Call fetchMetrics inside status loop
|
| 211 |
-
const originalCheckStatus = checkStatus;
|
| 212 |
-
checkStatus = async function() {
|
| 213 |
-
await originalCheckStatus();
|
| 214 |
-
await fetchMetrics();
|
| 215 |
-
};
|
| 216 |
-
|
| 217 |
-
// Generic Symbols Modal
|
| 218 |
-
const symbolsModal = document.getElementById('symbols-modal');
|
| 219 |
-
const closeSymbolsBtn = document.getElementById('close-symbols-btn');
|
| 220 |
-
const symbolsTitle = document.getElementById('symbols-modal-title');
|
| 221 |
-
const symbolsContainer = document.getElementById('symbols-list-container');
|
| 222 |
-
|
| 223 |
-
function showSymbolsList(title, symbols) {
|
| 224 |
-
symbolsTitle.textContent = title;
|
| 225 |
-
symbolsContainer.innerHTML = '';
|
| 226 |
-
if (!symbols || symbols.length === 0) {
|
| 227 |
-
symbolsContainer.innerHTML = '<div style="grid-column: 1/-1; text-align: center; color: var(--text-muted); padding: 2rem;">No symbols available</div>';
|
| 228 |
-
} else {
|
| 229 |
-
symbols.forEach(sym => {
|
| 230 |
-
const badge = document.createElement('div');
|
| 231 |
-
badge.className = 'symbol-badge';
|
| 232 |
-
badge.textContent = sym;
|
| 233 |
-
symbolsContainer.appendChild(badge);
|
| 234 |
-
});
|
| 235 |
-
}
|
| 236 |
-
symbolsModal.style.display = 'block';
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
document.getElementById('metric-total-symbols').addEventListener('click', () => {
|
| 240 |
-
showSymbolsList('Total Symbols List', currentMetrics.total_symbols.symbols);
|
| 241 |
-
});
|
| 242 |
-
document.getElementById('metric-tuned-symbols').addEventListener('click', () => {
|
| 243 |
-
showSymbolsList('Tuned Symbols List', currentMetrics.tuned_symbols.symbols);
|
| 244 |
-
});
|
| 245 |
-
document.getElementById('metric-trained-symbols').addEventListener('click', () => {
|
| 246 |
-
showSymbolsList('Trained Symbols List', currentMetrics.trained_symbols.symbols);
|
| 247 |
-
});
|
| 248 |
-
document.getElementById('metric-backtest-graphs').addEventListener('click', () => {
|
| 249 |
-
showSymbolsList('Backtest Graphs Available', currentMetrics.backtest_graphs.symbols);
|
| 250 |
-
});
|
| 251 |
-
|
| 252 |
-
closeSymbolsBtn.addEventListener('click', () => {
|
| 253 |
-
symbolsModal.style.display = 'none';
|
| 254 |
-
});
|
| 255 |
-
|
| 256 |
-
window.addEventListener('click', (e) => {
|
| 257 |
-
if (e.target === symbolsModal) {
|
| 258 |
-
symbolsModal.style.display = 'none';
|
| 259 |
-
}
|
| 260 |
-
});
|
| 261 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/task_manager.py
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 1 |
-
import threading
|
| 2 |
-
import logging
|
| 3 |
-
from collections import deque
|
| 4 |
-
import traceback
|
| 5 |
-
|
| 6 |
-
class TaskManager:
|
| 7 |
-
def __init__(self):
|
| 8 |
-
self.current_task = None
|
| 9 |
-
self.status = "Idle"
|
| 10 |
-
self.completed_count = 0
|
| 11 |
-
self.total_count = 0
|
| 12 |
-
self.logs = deque(maxlen=500)
|
| 13 |
-
self.lock = threading.Lock()
|
| 14 |
-
self.setup_custom_logger()
|
| 15 |
-
|
| 16 |
-
def update_progress(self, completed, total, status=None):
|
| 17 |
-
with self.lock:
|
| 18 |
-
self.completed_count = completed
|
| 19 |
-
self.total_count = total
|
| 20 |
-
if status is not None:
|
| 21 |
-
self.status = status
|
| 22 |
-
|
| 23 |
-
def increment_progress(self):
|
| 24 |
-
with self.lock:
|
| 25 |
-
self.completed_count += 1
|
| 26 |
-
|
| 27 |
-
def setup_custom_logger(self):
|
| 28 |
-
class DequeHandler(logging.Handler):
|
| 29 |
-
def __init__(self, deque_obj):
|
| 30 |
-
super().__init__()
|
| 31 |
-
self.deque = deque_obj
|
| 32 |
-
|
| 33 |
-
def emit(self, record):
|
| 34 |
-
try:
|
| 35 |
-
msg = self.format(record)
|
| 36 |
-
self.deque.append(msg)
|
| 37 |
-
except Exception:
|
| 38 |
-
self.handleError(record)
|
| 39 |
-
|
| 40 |
-
self.handler = DequeHandler(self.logs)
|
| 41 |
-
self.handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
| 42 |
-
logging.getLogger().addHandler(self.handler)
|
| 43 |
-
# Ensure base logging level is INFO
|
| 44 |
-
logging.getLogger().setLevel(logging.INFO)
|
| 45 |
-
|
| 46 |
-
def start_task(self, name, func, *args, **kwargs):
|
| 47 |
-
with self.lock:
|
| 48 |
-
if self.current_task and self.current_task.is_alive():
|
| 49 |
-
return False, "A task is already running."
|
| 50 |
-
|
| 51 |
-
self.status = f"Running: {name}"
|
| 52 |
-
self.completed_count = 0
|
| 53 |
-
self.total_count = 0
|
| 54 |
-
self.logs.clear()
|
| 55 |
-
logging.info(f"--- Started Task: {name} ---")
|
| 56 |
-
|
| 57 |
-
import nsereco.utils as utils
|
| 58 |
-
utils.interrupted = False
|
| 59 |
-
|
| 60 |
-
def task_wrapper():
|
| 61 |
-
try:
|
| 62 |
-
func(*args, **kwargs)
|
| 63 |
-
logging.info(f"--- Task {name} Completed Successfully ---")
|
| 64 |
-
except Exception as e:
|
| 65 |
-
logging.error(f"Task Failed: {e}")
|
| 66 |
-
logging.error(traceback.format_exc())
|
| 67 |
-
finally:
|
| 68 |
-
with self.lock:
|
| 69 |
-
self.status = "Idle"
|
| 70 |
-
self.completed_count = 0
|
| 71 |
-
self.total_count = 0
|
| 72 |
-
|
| 73 |
-
self.current_task = threading.Thread(target=task_wrapper, daemon=True)
|
| 74 |
-
self.current_task.start()
|
| 75 |
-
return True, "Task started successfully."
|
| 76 |
-
|
| 77 |
-
def stop_task(self):
|
| 78 |
-
with self.lock:
|
| 79 |
-
if self.current_task and self.current_task.is_alive():
|
| 80 |
-
import nsereco.utils as utils
|
| 81 |
-
utils.interrupted = True
|
| 82 |
-
logging.info("--- Cancel Requested. Waiting for graceful shutdown... ---")
|
| 83 |
-
return True, "Stop signal sent. Task is shutting down."
|
| 84 |
-
return False, "No task is currently running."
|
| 85 |
-
|
| 86 |
-
def get_status(self):
|
| 87 |
-
with self.lock:
|
| 88 |
-
return {
|
| 89 |
-
"status": self.status,
|
| 90 |
-
"is_running": self.current_task is not None and self.current_task.is_alive(),
|
| 91 |
-
"completed_count": self.completed_count,
|
| 92 |
-
"total_count": self.total_count,
|
| 93 |
-
"logs": list(self.logs)
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
task_manager = TaskManager()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nsereco/templates/index.html
DELETED
|
@@ -1,134 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>NSEReco Dashboard</title>
|
| 7 |
-
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 8 |
-
<!-- Google Fonts for modern typography -->
|
| 9 |
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
|
| 10 |
-
</head>
|
| 11 |
-
<body>
|
| 12 |
-
<div class="app-container">
|
| 13 |
-
<header class="app-header">
|
| 14 |
-
<h1>NSEReco Dashboard</h1>
|
| 15 |
-
<div class="status-indicator">
|
| 16 |
-
<span class="status-dot"></span>
|
| 17 |
-
<span id="system-status">Checking status...</span>
|
| 18 |
-
</div>
|
| 19 |
-
</header>
|
| 20 |
-
|
| 21 |
-
<!-- Metrics Grid -->
|
| 22 |
-
<section class="metrics-grid">
|
| 23 |
-
<div class="metric-card glassmorphism">
|
| 24 |
-
<div class="metric-label">Total Symbols</div>
|
| 25 |
-
<div class="metric-value clickable" id="metric-total-symbols">-</div>
|
| 26 |
-
</div>
|
| 27 |
-
<div class="metric-card glassmorphism">
|
| 28 |
-
<div class="metric-label">Tuned Symbols</div>
|
| 29 |
-
<div class="metric-value clickable" id="metric-tuned-symbols">-</div>
|
| 30 |
-
</div>
|
| 31 |
-
<div class="metric-card glassmorphism">
|
| 32 |
-
<div class="metric-label">Trained Symbols</div>
|
| 33 |
-
<div class="metric-value clickable" id="metric-trained-symbols">-</div>
|
| 34 |
-
</div>
|
| 35 |
-
<div class="metric-card glassmorphism">
|
| 36 |
-
<div class="metric-label">Backtest Graphs</div>
|
| 37 |
-
<div class="metric-value clickable" id="metric-backtest-graphs">-</div>
|
| 38 |
-
</div>
|
| 39 |
-
<div class="metric-card glassmorphism">
|
| 40 |
-
<div class="metric-label">Tuning Status</div>
|
| 41 |
-
<div class="metric-value" id="metric-tuning-status">Idle</div>
|
| 42 |
-
</div>
|
| 43 |
-
<div class="metric-card glassmorphism">
|
| 44 |
-
<div class="metric-label">Training Status</div>
|
| 45 |
-
<div class="metric-value" id="metric-training-status">Idle</div>
|
| 46 |
-
</div>
|
| 47 |
-
</section>
|
| 48 |
-
|
| 49 |
-
<div class="top-actions" style="display: flex; gap: 1rem; margin-bottom: 1rem; justify-content: flex-end;">
|
| 50 |
-
<button id="btn-view-graphs" class="btn secondary" style="width: auto; padding: 0.5rem 1.5rem;">View Graphs</button>
|
| 51 |
-
<button id="btn-stop" class="btn warning" style="width: auto; padding: 0.5rem 1.5rem;" disabled>Stop Task</button>
|
| 52 |
-
<button id="btn-kill" class="btn danger" style="width: auto; padding: 0.5rem 1.5rem;">Kill App</button>
|
| 53 |
-
</div>
|
| 54 |
-
|
| 55 |
-
<main class="dashboard-grid">
|
| 56 |
-
<!-- Card 1: Initialization -->
|
| 57 |
-
<div class="card glassmorphism">
|
| 58 |
-
<h2>System Setup</h2>
|
| 59 |
-
<p>Initialize the data manager, load datasets, and engineer 50+ features. This must be done before running models.</p>
|
| 60 |
-
<button id="btn-init" class="btn primary">Initialize System</button>
|
| 61 |
-
</div>
|
| 62 |
-
|
| 63 |
-
<!-- Card 2: Hyperparameter Tuning -->
|
| 64 |
-
<div class="card glassmorphism">
|
| 65 |
-
<h2>1. Optuna tuning</h2>
|
| 66 |
-
<p>Check list of stock for pending tuning, prioritize nifty stocks, trigger number of symbols / threads for tuning via the Agent Workflow.</p>
|
| 67 |
-
<div class="form-group">
|
| 68 |
-
<label for="tune-stocks">Number of Stocks:</label>
|
| 69 |
-
<input type="number" id="tune-stocks" value="20" min="1" max="50">
|
| 70 |
-
</div>
|
| 71 |
-
<div class="form-group">
|
| 72 |
-
<label for="tune-threads">Worker Threads:</label>
|
| 73 |
-
<input type="number" id="tune-threads" value="8" min="1" max="16">
|
| 74 |
-
</div>
|
| 75 |
-
<button id="btn-tune" class="btn primary">Start Tuning</button>
|
| 76 |
-
</div>
|
| 77 |
-
|
| 78 |
-
<!-- Card 3: Model Training -->
|
| 79 |
-
<div class="card glassmorphism">
|
| 80 |
-
<h2>2. Model training</h2>
|
| 81 |
-
<p>Check for tuning completed but pending training, get best hyperparameters, prioritize nifty stocks, trigger training via the Agent Workflow.</p>
|
| 82 |
-
<div class="form-group">
|
| 83 |
-
<label for="train-stocks">Number of Stocks:</label>
|
| 84 |
-
<input type="number" id="train-stocks" value="20" min="1" max="50">
|
| 85 |
-
</div>
|
| 86 |
-
<div class="form-group">
|
| 87 |
-
<label for="train-threads">Worker Threads:</label>
|
| 88 |
-
<input type="number" id="train-threads" value="8" min="1" max="16">
|
| 89 |
-
</div>
|
| 90 |
-
<button id="btn-train" class="btn primary">Start Training</button>
|
| 91 |
-
</div>
|
| 92 |
-
</main>
|
| 93 |
-
|
| 94 |
-
<section class="console-section">
|
| 95 |
-
<div class="console-header">
|
| 96 |
-
<h3>Live Console Output</h3>
|
| 97 |
-
</div>
|
| 98 |
-
<div id="console-output" class="console-window">
|
| 99 |
-
<!-- Logs will be appended here -->
|
| 100 |
-
</div>
|
| 101 |
-
</section>
|
| 102 |
-
</div>
|
| 103 |
-
|
| 104 |
-
<!-- Graph Modal -->
|
| 105 |
-
<div id="graph-modal" class="modal">
|
| 106 |
-
<div class="modal-content glassmorphism">
|
| 107 |
-
<span class="close-btn">×</span>
|
| 108 |
-
<h2>Backtest Graphs</h2>
|
| 109 |
-
<div class="form-group">
|
| 110 |
-
<label for="graph-select">Select a graph:</label>
|
| 111 |
-
<select id="graph-select">
|
| 112 |
-
<option value="">No graphs available</option>
|
| 113 |
-
</select>
|
| 114 |
-
</div>
|
| 115 |
-
<div class="graph-container">
|
| 116 |
-
<img id="graph-img" src="" alt="Backtest Graph" style="display: none; width: 100%; border-radius: 8px;">
|
| 117 |
-
</div>
|
| 118 |
-
</div>
|
| 119 |
-
</div>
|
| 120 |
-
|
| 121 |
-
<!-- Symbols List Modal -->
|
| 122 |
-
<div id="symbols-modal" class="modal">
|
| 123 |
-
<div class="modal-content glassmorphism">
|
| 124 |
-
<span class="close-btn" id="close-symbols-btn">×</span>
|
| 125 |
-
<h2 id="symbols-modal-title">Symbols List</h2>
|
| 126 |
-
<div id="symbols-list-container" class="symbols-list-grid">
|
| 127 |
-
<!-- Symbols will be populated here -->
|
| 128 |
-
</div>
|
| 129 |
-
</div>
|
| 130 |
-
</div>
|
| 131 |
-
|
| 132 |
-
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
| 133 |
-
</body>
|
| 134 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|