Sam-max1 commited on
Commit
3c72b8f
·
verified ·
1 Parent(s): 9ebf339

Deploy updated nsedash (clean obfuscated deployment)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +0 -11
  2. app.py +1 -1
  3. nsecorpact.py +0 -284
  4. nsedash/__init__.py +0 -0
  5. nsedash/config.py +0 -221
  6. nsedash/core/__init__.py +0 -0
  7. nsedash/core/dashboard_service.py +0 -887
  8. nsedash/core/hf_sync.py +0 -338
  9. nsedash/core/live_fetch.py +0 -361
  10. nsedash/core/nvidia_llm.py +0 -933
  11. nsedash/core/nvidia_model_registry.json +0 -981
  12. nsedash/core/portfolio.py +0 -311
  13. nsedash/core/recommender_bridge.py +0 -110
  14. nsedash/core/stock_data.py +0 -504
  15. nsedash/core/token_state.json +0 -37
  16. nsedash/core/user_auth.py +0 -835
  17. nsedash/core/userdata_db.py +0 -206
  18. nsedash/core/watchlists.py +0 -262
  19. nsedash/crew/__init__.py +0 -0
  20. nsedash/crew/agents.py +0 -221
  21. nsedash/crew/crews.py +0 -128
  22. nsedash/crew/framework.py +0 -151
  23. nsedash/logging_utils.py +0 -82
  24. nsedash/web/__init__.py +0 -112
  25. nsedash/web/api.py +0 -1196
  26. nsedash/web/background.py +0 -281
  27. nsedash/web/routes.py +0 -14
  28. nsedash/web/static/app.js +0 -1548
  29. nsedash/web/static/style.css +0 -924
  30. nsedash/web/templates/index.html +0 -489
  31. nsedash_admin.py +0 -479
  32. nsedash_api.py +0 -653
  33. nsedash_app.py +0 -212
  34. nsedash_app.zip +2 -2
  35. nsedash_start.py +1 -1
  36. nsedown.py +0 -479
  37. nsemaxlive.py +0 -553
  38. nsemaxmin.py +0 -147
  39. nsemcupdate.py +0 -418
  40. nseprocessor.py +0 -1258
  41. nsereco/__init__.py +0 -22
  42. nsereco/agent_workflow.py +0 -229
  43. nsereco/data.py +0 -749
  44. nsereco/models.py +0 -274
  45. nsereco/recommender.py +0 -0
  46. nsereco/routes.py +0 -260
  47. nsereco/static/css/style.css +0 -377
  48. nsereco/static/js/app.js +0 -261
  49. nsereco/task_manager.py +0 -96
  50. 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, "&amp;")
13
- .replace(/</g, "&lt;")
14
- .replace(/>/g, "&gt;")
15
- .replace(/"/g, "&quot;")
16
- .replace(/'/g, "&#039;");
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
- &nbsp;H ${fmt(c.live.day_high)} L ${fmt(c.live.day_low)}
499
- &nbsp;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&amp;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;">&times;</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:8af521a8d35b3db6a137744d3f22231165db988c9478722c77c97eb6289d300e
3
- size 969257
 
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">&times;</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">&times;</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>