├── models.py ├── requirements.txt ├── .gitignore ├── LICENSE ├── README.md ├── app.py ├── templates └── index.html └── static └── main.js /models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from datetime import datetime 3 | 4 | db = SQLAlchemy() 5 | 6 | class Symbol(db.Model): 7 | id = db.Column(db.Integer, primary_key=True) 8 | ticker = db.Column(db.String(10), unique=True, nullable=False) 9 | name = db.Column(db.String(100)) 10 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 11 | 12 | def to_dict(self): 13 | return { 14 | 'id': self.id, 15 | 'ticker': self.ticker, 16 | 'name': self.name, 17 | 'created_at': self.created_at.isoformat() if self.created_at else None 18 | } 19 | 20 | def __repr__(self): 21 | return f'' 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.12.3 2 | blinker==1.8.2 3 | certifi==2024.2.2 4 | charset-normalizer==3.3.2 5 | click==8.1.7 6 | colorama==0.4.6 7 | Flask==3.0.3 8 | Flask-SQLAlchemy==3.1.1 9 | frozendict==2.4.4 10 | html5lib==1.1 11 | idna==3.7 12 | itsdangerous==2.2.0 13 | Jinja2==3.1.4 14 | lxml==5.2.2 15 | MarkupSafe==2.1.5 16 | multitasking==0.0.11 17 | numpy==1.26.4 18 | pandas==2.2.2 19 | pandas_ta==0.3.14b0 20 | peewee==3.17.5 21 | platformdirs==4.2.2 22 | python-dateutil==2.9.0.post0 23 | pytz==2024.1 24 | requests==2.31.0 25 | setuptools==78.1.0 26 | six==1.16.0 27 | soupsieve==2.5 28 | SQLAlchemy==2.0.40 29 | typing_extensions==4.13.2 30 | tzdata==2024.1 31 | urllib3==2.2.1 32 | webencodings==0.5.1 33 | Werkzeug==3.0.3 34 | yfinance==0.2.61 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual Environment 2 | venv/ 3 | env/ 4 | .env/ 5 | .venv/ 6 | 7 | # Python 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | *.so 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # Flask 30 | instance/ 31 | .webassets-cache 32 | 33 | # Database 34 | *.sqlite 35 | *.sqlite3 36 | *.db 37 | 38 | # IDE - VS Code 39 | .vscode/ 40 | .history 41 | 42 | # IDE - PyCharm 43 | .idea/ 44 | *.iml 45 | *.iws 46 | *.ipr 47 | 48 | # IDE - Jupyter Notebook 49 | .ipynb_checkpoints 50 | 51 | # OS specific 52 | .DS_Store 53 | Thumbs.db 54 | 55 | # Logs 56 | logs/ 57 | *.log 58 | 59 | # Environment variables 60 | .env 61 | .env.local 62 | .env.development 63 | .env.test 64 | .env.production -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | 22 | --- 23 | 24 | This software uses TradingView Lightweight Charts which is licensed under the Apache License, Version 2.0 (the "License"); you may not use this software except in compliance with the License. You may obtain a copy of the License at: 25 | 26 | http://www.apache.org/licenses/LICENSE-2.0 27 | 28 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 29 | 30 | This software incorporates several parts of tslib (https://github.com/Microsoft/tslib, (c) Microsoft Corporation) that are covered by BSD Zero Clause License. 31 | 32 | ### Attribution Notice 33 | This product includes software developed by TradingView. For more information, please visit: 34 | 35 | https://www.tradingview.com/ 36 | 37 | In compliance with the TradingView Lightweight Charts license, you shall add this attribution notice to the page of your website or mobile application that is available to your users. 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TradingView Chart with Yahoo Finance Data 2 | 3 | This project is a web application that displays TradingView lightweight charts with real-time stock data fetched from Yahoo Finance. The application features an interactive UI built with Flask, SQLAlchemy, and Tailwind CSS with DaisyUI components. Users can view and analyze stock data using technical indicators, manage a watchlist of symbols, and toggle between light and dark themes. 4 | 5 | ## Features 6 | 7 | ### Chart Analysis 8 | - Fetch and display real-time stock data from Yahoo Finance using the yfinance library 9 | - View stock data in multiple timeframes (1 minute, 5 minutes, 15 minutes, hourly, daily, weekly, monthly) 10 | - Technical analysis indicators including EMA and RSI 11 | - Auto-update option for real-time data monitoring 12 | 13 | ### Watchlist Management 14 | - Dynamic watchlist with real-time quotes for multiple symbols 15 | - Add and remove symbols from the watchlist with instant UI updates 16 | - Persistent storage of watchlist symbols in SQLite database 17 | - Smooth animations for symbol addition and removal 18 | 19 | ### User Interface 20 | - Modern, responsive UI built with Tailwind CSS and DaisyUI components 21 | - Toggle between light and dark themes 22 | - Mobile-friendly design with drawer navigation 23 | 24 | ## Installation 25 | 26 | Follow these steps to set up and run the application: 27 | 28 | ### Prerequisites 29 | 30 | - Python 3.x 31 | 32 | ### Steps 33 | 34 | 1. **Clone the repository:** 35 | 36 | ```sh 37 | git clone https://github.com/marketcalls/tradingview-yahoo-finance.git 38 | cd tradingview-yahoo-finance 39 | ``` 40 | 41 | 2. **Create a virtual environment:** 42 | 43 | Windows: 44 | ```sh 45 | python -m venv venv 46 | venv\Scripts\activate 47 | ``` 48 | 49 | macOS/Linux: 50 | ```sh 51 | python -m venv venv 52 | source venv/bin/activate 53 | ``` 54 | 55 | 3. **Install the dependencies:** 56 | 57 | ```sh 58 | pip install -r requirements.txt 59 | ``` 60 | 61 | 4. **Run the Flask application:** 62 | 63 | ```sh 64 | python app.py 65 | ``` 66 | 67 | 5. **Open your web browser and visit:** 68 | 69 | ``` 70 | http://127.0.0.1:5000 71 | ``` 72 | 73 | ## Project Structure 74 | 75 | ``` 76 | ├── app.py # Main Flask application 77 | ├── models.py # SQLAlchemy database models 78 | ├── symbols.txt # Default symbols file 79 | ├── templates/ 80 | │ └── index.html # Main HTML template 81 | ├── static/ 82 | │ └── main.js # JavaScript for chart handling and UI 83 | ├── .gitignore # Git ignore file 84 | └── README.md # Project documentation 85 | ``` 86 | 87 | ## Technologies Used 88 | 89 | - **Backend**: Flask, SQLAlchemy, SQLite 90 | - **Data**: Yahoo Finance API (via yfinance) 91 | - **Frontend**: JavaScript, Tailwind CSS, DaisyUI 92 | - **Charting**: Lightweight-charts.js 93 | 94 | ## License 95 | 96 | This project is licensed under the MIT License. -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, jsonify, request 2 | import yfinance as yf 3 | import pandas as pd 4 | import pandas_ta as ta 5 | from datetime import datetime, timedelta 6 | import json 7 | import os 8 | from models import db, Symbol 9 | 10 | app = Flask(__name__) 11 | 12 | # Configure SQLAlchemy with SQLite 13 | basedir = os.path.abspath(os.path.dirname(__file__)) 14 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'symbols.db') 15 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 16 | 17 | # Initialize database 18 | db.init_app(app) 19 | 20 | def fetch_yahoo_data(ticker, interval, ema_period=20, rsi_period=14): 21 | ticker = yf.Ticker(ticker) 22 | 23 | end_date = datetime.now() 24 | if interval in ['1m', '5m']: 25 | start_date = end_date - timedelta(days=7) 26 | elif interval in ['15m', '60m']: 27 | start_date = end_date - timedelta(days=60) 28 | elif interval == '1d': 29 | start_date = end_date - timedelta(days=365*5) 30 | elif interval == '1wk': 31 | start_date = end_date - timedelta(weeks=365*5) 32 | elif interval == '1mo': 33 | start_date = end_date - timedelta(days=365*5) 34 | 35 | data = ticker.history(start=start_date, end=end_date, interval=interval) 36 | data['EMA'] = ta.ema(data['Close'], length=ema_period) 37 | data['RSI'] = ta.rsi(data['Close'], length=rsi_period) 38 | 39 | candlestick_data = [ 40 | { 41 | 'time': int(row.Index.timestamp()), 42 | 'open': row.Open, 43 | 'high': row.High, 44 | 'low': row.Low, 45 | 'close': row.Close 46 | } 47 | for row in data.itertuples() 48 | ] 49 | 50 | ema_data = [ 51 | { 52 | 'time': int(row.Index.timestamp()), 53 | 'value': row.EMA 54 | } 55 | for row in data.itertuples() if not pd.isna(row.EMA) 56 | ] 57 | 58 | rsi_data = [ 59 | { 60 | 'time': int(row.Index.timestamp()), 61 | 'value': row.RSI if not pd.isna(row.RSI) else 0 # Convert NaN to zero 62 | } 63 | for row in data.itertuples() 64 | ] 65 | 66 | return candlestick_data, ema_data, rsi_data 67 | 68 | @app.route('/') 69 | def index(): 70 | return render_template('index.html') 71 | 72 | @app.route('/api/data////') 73 | def get_data(ticker, interval, ema_period, rsi_period): 74 | candlestick_data, ema_data, rsi_data = fetch_yahoo_data(ticker, interval, ema_period, rsi_period) 75 | return jsonify({'candlestick': candlestick_data, 'ema': ema_data, 'rsi': rsi_data}) 76 | 77 | # Create database tables on startup if they don't exist 78 | with app.app_context(): 79 | db.create_all() 80 | 81 | # Add default symbols if the database is empty 82 | if Symbol.query.count() == 0: 83 | default_symbols = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'TSLA', 'NVDA', 'NFLX'] 84 | for ticker in default_symbols: 85 | if not Symbol.query.filter_by(ticker=ticker).first(): 86 | symbol = Symbol(ticker=ticker) 87 | db.session.add(symbol) 88 | db.session.commit() 89 | print(f'Added {len(default_symbols)} default symbols') 90 | 91 | @app.route('/api/symbols') 92 | def get_symbols(): 93 | # Get symbols from database 94 | db_symbols = Symbol.query.all() 95 | symbol_list = [symbol.ticker for symbol in db_symbols] 96 | 97 | # Get real quotes for symbols 98 | try: 99 | if not symbol_list: 100 | return jsonify([]) 101 | 102 | symbols_str = ' '.join(symbol_list) 103 | tickers = yf.Tickers(symbols_str) 104 | 105 | symbols_data = [] 106 | for symbol in db_symbols: 107 | try: 108 | ticker_info = tickers.tickers[symbol.ticker].info 109 | quote_data = { 110 | 'id': symbol.id, 111 | 'symbol': symbol.ticker, 112 | 'price': ticker_info.get('currentPrice', 0), 113 | 'change': ticker_info.get('regularMarketChangePercent', 0), 114 | 'name': ticker_info.get('shortName', symbol.ticker), 115 | } 116 | symbols_data.append(quote_data) 117 | except Exception as e: 118 | # Fallback data if we can't get info for a particular symbol 119 | symbols_data.append({ 120 | 'id': symbol.id, 121 | 'symbol': symbol.ticker, 122 | 'price': 0, 123 | 'change': 0, 124 | 'name': symbol.ticker, 125 | }) 126 | print(f"Error getting data for {symbol.ticker}: {e}") 127 | 128 | return jsonify(symbols_data) 129 | 130 | except Exception as e: 131 | print(f"Error fetching quotes: {e}") 132 | # Fallback to just returning the symbols without data 133 | return jsonify([{'id': s.id, 'symbol': s.ticker, 'price': 0, 'change': 0, 'name': s.ticker} for s in db_symbols]) 134 | 135 | @app.route('/api/symbols', methods=['POST']) 136 | def add_symbol(): 137 | data = request.json 138 | if not data or 'symbol' not in data: 139 | return jsonify({'error': 'Symbol is required'}), 400 140 | 141 | ticker = data['symbol'].strip().upper() 142 | if not ticker: 143 | return jsonify({'error': 'Symbol cannot be empty'}), 400 144 | 145 | # Check if symbol already exists 146 | existing = Symbol.query.filter_by(ticker=ticker).first() 147 | if existing: 148 | return jsonify({'error': 'Symbol already exists', 'symbol': existing.to_dict()}), 409 149 | 150 | # Validate symbol with yfinance 151 | try: 152 | info = yf.Ticker(ticker).info 153 | if 'regularMarketPrice' not in info and 'currentPrice' not in info: 154 | return jsonify({'error': 'Invalid symbol'}), 400 155 | 156 | # Add symbol to database 157 | symbol = Symbol(ticker=ticker, name=info.get('shortName', ticker)) 158 | db.session.add(symbol) 159 | db.session.commit() 160 | 161 | return jsonify({'message': 'Symbol added successfully', 'symbol': symbol.to_dict()}), 201 162 | except Exception as e: 163 | return jsonify({'error': f'Error adding symbol: {str(e)}'}), 400 164 | 165 | @app.route('/api/symbols/', methods=['DELETE']) 166 | def delete_symbol(symbol_id): 167 | symbol = Symbol.query.get_or_404(symbol_id) 168 | db.session.delete(symbol) 169 | db.session.commit() 170 | return jsonify({'message': 'Symbol deleted successfully'}), 200 171 | 172 | if __name__ == '__main__': 173 | app.run(debug=True) -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TradingView Chart with Yahoo Finance Data 7 | 8 | 9 | 10 | 53 | 143 | 144 | 145 |
146 | 147 | 148 | 149 |
150 | 172 | 173 |
174 |
175 |
176 |
177 |
178 | 181 |
182 |
183 | 184 | 187 |
188 |
189 |
190 | 191 |
192 | 195 | 204 |
205 | 206 |
207 | 210 | 211 |
212 | 213 |
214 | 217 | 218 |
219 | 220 |
221 | 224 |
225 |
226 | 227 | Enable 228 |
229 |
230 | 231 | sec 232 |
233 |
234 |
235 | 236 |
237 | 241 |
242 |
243 |
244 |
245 | 246 |
247 |
248 |
249 |
250 | 251 | 252 |
253 | 254 | 261 |
262 |
263 | 264 | 265 | 266 | 283 | 284 | 285 | -------------------------------------------------------------------------------- /static/main.js: -------------------------------------------------------------------------------- 1 | // Check if dark mode is active 2 | const isDarkMode = document.body.classList.contains('dark'); 3 | 4 | // Initial chart setup with improved styling 5 | const chartOptions1 = { 6 | layout: { 7 | background: { type: 'solid', color: isDarkMode ? '#111827' : 'white' }, 8 | textColor: isDarkMode ? '#f3f4f6' : '#1f2937', 9 | fontFamily: 'Inter, sans-serif', 10 | }, 11 | grid: { 12 | vertLines: { 13 | color: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)', 14 | style: 1, // Solid line style 15 | }, 16 | horzLines: { 17 | color: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)', 18 | style: 1, // Solid line style 19 | }, 20 | }, 21 | crosshair: { 22 | mode: LightweightCharts.CrosshairMode.Normal, 23 | vertLine: { 24 | color: isDarkMode ? 'rgba(156, 163, 175, 0.5)' : 'rgba(75, 85, 99, 0.3)', 25 | width: 1, 26 | style: 2, // Dashed line 27 | }, 28 | horzLine: { 29 | color: isDarkMode ? 'rgba(156, 163, 175, 0.5)' : 'rgba(75, 85, 99, 0.3)', 30 | width: 1, 31 | style: 2, // Dashed line 32 | }, 33 | }, 34 | timeScale: { 35 | visible: false, 36 | borderColor: isDarkMode ? '#374151' : '#e5e7eb', 37 | timeVisible: true, 38 | secondsVisible: false, 39 | }, 40 | rightPriceScale: { 41 | borderColor: isDarkMode ? '#374151' : '#e5e7eb', 42 | scaleMargins: { 43 | top: 0.1, 44 | bottom: 0.2, 45 | }, 46 | }, 47 | width: document.getElementById('chart').clientWidth, 48 | height: document.getElementById('chart').clientHeight, 49 | }; 50 | 51 | const chartOptions2 = { 52 | layout: { 53 | background: { type: 'solid', color: isDarkMode ? '#111827' : 'white' }, 54 | textColor: isDarkMode ? '#f3f4f6' : '#1f2937', 55 | fontFamily: 'Inter, sans-serif', 56 | }, 57 | grid: { 58 | vertLines: { 59 | color: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)', 60 | style: 1, 61 | }, 62 | horzLines: { 63 | color: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)', 64 | style: 1, 65 | }, 66 | }, 67 | crosshair: { 68 | mode: LightweightCharts.CrosshairMode.Normal, 69 | vertLine: { 70 | color: isDarkMode ? 'rgba(156, 163, 175, 0.5)' : 'rgba(75, 85, 99, 0.3)', 71 | width: 1, 72 | style: 2, 73 | }, 74 | horzLine: { 75 | color: isDarkMode ? 'rgba(156, 163, 175, 0.5)' : 'rgba(75, 85, 99, 0.3)', 76 | width: 1, 77 | style: 2, 78 | }, 79 | }, 80 | timeScale: { 81 | visible: true, 82 | borderColor: isDarkMode ? '#374151' : '#e5e7eb', 83 | timeVisible: true, 84 | secondsVisible: false, 85 | }, 86 | rightPriceScale: { 87 | borderColor: isDarkMode ? '#374151' : '#e5e7eb', 88 | scaleMargins: { 89 | top: 0.1, 90 | bottom: 0.2, 91 | }, 92 | }, 93 | width: document.getElementById('chart').clientWidth, 94 | height: document.getElementById('rsiChart').clientHeight, 95 | }; 96 | 97 | const chart = LightweightCharts.createChart(document.getElementById('chart'), chartOptions1); 98 | const candlestickSeries = chart.addSeries( 99 | LightweightCharts.CandlestickSeries, 100 | { 101 | upColor: '#26a69a', 102 | downColor: '#ef5350', 103 | borderVisible: false, 104 | wickUpColor: '#26a69a', 105 | wickDownColor: '#ef5350', 106 | } 107 | ); 108 | const emaLine = chart.addSeries( 109 | LightweightCharts.LineSeries, 110 | { color: 'blue', lineWidth: 2 } 111 | ); 112 | 113 | const rsiChart = LightweightCharts.createChart(document.getElementById('rsiChart'),chartOptions2); 114 | const rsiLine = rsiChart.addSeries( 115 | LightweightCharts.LineSeries, 116 | { color: 'red', lineWidth: 2} 117 | ); 118 | 119 | let autoUpdateInterval; 120 | 121 | // Fetch data function 122 | function fetchData(ticker, timeframe, emaPeriod, rsiPeriod) { 123 | fetch(`/api/data/${ticker}/${timeframe}/${emaPeriod}/${rsiPeriod}`) 124 | .then(response => response.json()) 125 | .then(data => { 126 | candlestickSeries.setData(data.candlestick); 127 | emaLine.setData(data.ema); 128 | rsiLine.setData(data.rsi); 129 | 130 | 131 | }) 132 | .catch(error => { 133 | console.error('Error fetching data:', error); 134 | }); 135 | } 136 | 137 | // Fetch NVDA data on page load with default timeframe (daily), EMA period (20) and RSI period (14) 138 | window.addEventListener('load', () => { 139 | fetchData('NVDA', '1d', 20, 14); 140 | loadWatchlist(); 141 | }); 142 | 143 | // Handle data fetching on button click 144 | document.getElementById('fetchData').addEventListener('click', () => { 145 | const ticker = document.getElementById('ticker').value; 146 | const timeframe = document.getElementById('timeframe').value; 147 | const emaPeriod = document.getElementById('emaPeriod').value; 148 | const rsiPeriod = document.getElementById('rsiPeriod').value; 149 | fetchData(ticker, timeframe, emaPeriod, rsiPeriod); 150 | }); 151 | 152 | // Handle auto-update functionality 153 | document.getElementById('autoUpdate').addEventListener('change', (event) => { 154 | if (event.target.checked) { 155 | const frequency = document.getElementById('updateFrequency').value * 1000; 156 | autoUpdateInterval = setInterval(() => { 157 | const ticker = document.getElementById('ticker').value; 158 | const timeframe = document.getElementById('timeframe').value; 159 | const emaPeriod = document.getElementById('emaPeriod').value; 160 | const rsiPeriod = document.getElementById('rsiPeriod').value; 161 | fetchData(ticker, timeframe, emaPeriod, rsiPeriod); 162 | }, frequency); 163 | } else { 164 | clearInterval(autoUpdateInterval); 165 | } 166 | }); 167 | 168 | // Handle window resize 169 | window.addEventListener('resize', () => { 170 | chart.resize(document.getElementById('chart').clientWidth, document.getElementById('chart').clientHeight); 171 | rsiChart.resize(document.getElementById('rsiChart').clientWidth, document.getElementById('rsiChart').clientHeight); 172 | }); 173 | 174 | // Theme toggle functionality for DaisyUI 175 | document.getElementById('themeToggle').addEventListener('click', () => { 176 | const currentTheme = document.body.getAttribute('data-theme'); 177 | const darkIcon = document.querySelector('.dark-icon'); 178 | const lightIcon = document.querySelector('.light-icon'); 179 | 180 | if (currentTheme === 'light') { 181 | // Switch to dark theme 182 | document.body.setAttribute('data-theme', 'dark'); 183 | darkIcon.classList.add('hidden'); 184 | lightIcon.classList.remove('hidden'); 185 | 186 | // Update chart options for dark theme 187 | chart.applyOptions({ 188 | layout: { 189 | background: { type: 'solid', color: '#1f2937' }, 190 | textColor: '#f3f4f6', 191 | }, 192 | grid: { 193 | vertLines: { 194 | color: 'rgba(55, 65, 81, 0.5)', 195 | }, 196 | horzLines: { 197 | color: 'rgba(55, 65, 81, 0.5)', 198 | }, 199 | }, 200 | rightPriceScale: { 201 | borderColor: '#374151', 202 | }, 203 | timeScale: { 204 | borderColor: '#374151', 205 | }, 206 | }); 207 | 208 | rsiChart.applyOptions({ 209 | layout: { 210 | background: { type: 'solid', color: '#1f2937' }, 211 | textColor: '#f3f4f6', 212 | }, 213 | grid: { 214 | vertLines: { 215 | color: 'rgba(55, 65, 81, 0.5)', 216 | }, 217 | horzLines: { 218 | color: 'rgba(55, 65, 81, 0.5)', 219 | }, 220 | }, 221 | rightPriceScale: { 222 | borderColor: '#374151', 223 | }, 224 | timeScale: { 225 | borderColor: '#374151', 226 | }, 227 | }); 228 | } else { 229 | // Switch to light theme 230 | document.body.setAttribute('data-theme', 'light'); 231 | darkIcon.classList.remove('hidden'); 232 | lightIcon.classList.add('hidden'); 233 | 234 | // Update chart options for light theme 235 | chart.applyOptions({ 236 | layout: { 237 | background: { type: 'solid', color: 'white' }, 238 | textColor: '#1f2937', 239 | }, 240 | grid: { 241 | vertLines: { 242 | color: 'rgba(229, 231, 235, 0.8)', 243 | }, 244 | horzLines: { 245 | color: 'rgba(229, 231, 235, 0.8)', 246 | }, 247 | }, 248 | rightPriceScale: { 249 | borderColor: '#e5e7eb', 250 | }, 251 | timeScale: { 252 | borderColor: '#e5e7eb', 253 | }, 254 | }); 255 | 256 | rsiChart.applyOptions({ 257 | layout: { 258 | background: { type: 'solid', color: 'white' }, 259 | textColor: '#1f2937', 260 | }, 261 | grid: { 262 | vertLines: { 263 | color: 'rgba(229, 231, 235, 0.8)', 264 | }, 265 | horzLines: { 266 | color: 'rgba(229, 231, 235, 0.8)', 267 | }, 268 | }, 269 | rightPriceScale: { 270 | borderColor: '#e5e7eb', 271 | }, 272 | timeScale: { 273 | borderColor: '#e5e7eb', 274 | }, 275 | }); 276 | } 277 | }); 278 | 279 | // Load watchlist symbols from the server with real quotes 280 | function loadWatchlist() { 281 | // Get the watchlist container 282 | const watchlistContainer = document.getElementById('watchlist'); 283 | const watchlistItems = document.getElementById('watchlistItems'); 284 | 285 | // Check if add symbol form already exists and remove it 286 | const existingForm = document.getElementById('add-symbol-form'); 287 | if (existingForm) { 288 | existingForm.remove(); 289 | } 290 | 291 | // Create the add symbol form at the top 292 | const addForm = document.createElement('div'); 293 | addForm.id = 'add-symbol-form'; 294 | addForm.className = 'form-control mb-4 p-4 border-b border-base-300'; 295 | addForm.innerHTML = ` 296 |
297 | 299 | 302 |
303 | 304 | `; 305 | 306 | // Insert form before the watchlist items 307 | watchlistContainer.insertBefore(addForm, watchlistItems); 308 | 309 | // Add event listeners to the form 310 | document.getElementById('addSymbolBtn').addEventListener('click', addSymbol); 311 | document.getElementById('newSymbol').addEventListener('keyup', function(e) { 312 | if (e.key === 'Enter') { 313 | addSymbol(); 314 | } 315 | }); 316 | 317 | // Show loading state in the watchlist items 318 | watchlistItems.innerHTML = ` 319 |
320 | 321 | Loading quotes... 322 |
323 | `; 324 | 325 | fetch('/api/symbols') 326 | .then(response => response.json()) 327 | .then(symbolsData => { 328 | watchlistItems.innerHTML = ''; 329 | 330 | if (symbolsData.length === 0) { 331 | const emptyState = document.createElement('div'); 332 | emptyState.className = 'flex flex-col items-center justify-center p-6 text-center text-opacity-70'; 333 | emptyState.innerHTML = ` 334 | 335 |

No symbols in watchlist

336 | 337 | `; 338 | watchlistItems.appendChild(emptyState); 339 | return; 340 | } 341 | 342 | symbolsData.forEach(symbolData => { 343 | const item = document.createElement('div'); 344 | item.className = 'card bg-base-100 hover:bg-base-200 shadow-sm hover:shadow cursor-pointer transition-all group relative'; 345 | 346 | // Format the data 347 | const price = symbolData.price ? symbolData.price.toFixed(2) : 'N/A'; 348 | const changePercent = symbolData.change ? symbolData.change.toFixed(2) : 0; 349 | const isPositive = changePercent > 0; 350 | const changeClass = isPositive ? 'text-success' : (changePercent < 0 ? 'text-error' : 'text-gray-500'); 351 | const changeIcon = isPositive ? 'caret-up' : (changePercent < 0 ? 'caret-down' : 'minus'); 352 | 353 | // Create tooltip with more info 354 | const tooltipContent = `${symbolData.name || symbolData.symbol}`; 355 | 356 | item.innerHTML = ` 357 |
358 |
359 |
360 |

${symbolData.symbol}

361 |
362 | ${symbolData.name || 'Yahoo Finance'} 363 |
364 |
365 |
366 |
${price}
367 |
368 | 369 | ${changePercent}% 370 |
371 |
372 |
373 | 376 |
377 | `; 378 | 379 | item.addEventListener('click', () => { 380 | document.getElementById('ticker').value = symbolData.symbol; 381 | // Add active indicator 382 | document.querySelectorAll('.card.border-primary').forEach(el => { 383 | el.classList.remove('border-primary', 'border'); 384 | }); 385 | item.classList.add('border-primary', 'border'); 386 | 387 | fetchData(symbolData.symbol, document.getElementById('timeframe').value, document.getElementById('emaPeriod').value, document.getElementById('rsiPeriod').value); 388 | }); 389 | 390 | watchlistItems.appendChild(item); 391 | }); 392 | 393 | // Add symbol removal event handlers 394 | document.querySelectorAll('.delete-symbol').forEach(btn => { 395 | btn.addEventListener('click', function(e) { 396 | e.stopPropagation(); 397 | const symbolId = this.getAttribute('data-id'); 398 | if (confirm('Remove this symbol from watchlist?')) { 399 | removeSymbol(symbolId); 400 | } 401 | }); 402 | }); 403 | 404 | // Add a refresh button at the bottom 405 | const refreshButton = document.createElement('button'); 406 | refreshButton.className = 'btn btn-sm btn-ghost gap-2 mt-4 w-full'; 407 | refreshButton.innerHTML = ` Refresh Quotes`; 408 | refreshButton.addEventListener('click', loadWatchlist); 409 | watchlistItems.appendChild(refreshButton); 410 | }) 411 | .catch(error => { 412 | console.error('Error loading watchlist:', error); 413 | watchlistItems.innerHTML = ` 414 |
415 | 416 | Error loading watchlist data 417 | 418 |
419 | `; 420 | }); 421 | } 422 | 423 | // Sync visible logical range between charts 424 | function syncVisibleLogicalRange(chart1, chart2) { 425 | chart1.timeScale().subscribeVisibleLogicalRangeChange(timeRange => { 426 | chart2.timeScale().setVisibleLogicalRange(timeRange); 427 | }); 428 | 429 | chart2.timeScale().subscribeVisibleLogicalRangeChange(timeRange => { 430 | chart1.timeScale().setVisibleLogicalRange(timeRange); 431 | }); 432 | } 433 | 434 | 435 | syncVisibleLogicalRange(chart, rsiChart); 436 | 437 | // Sync crosshair position between charts 438 | function getCrosshairDataPoint(series, param) { 439 | if (!param.time) { 440 | return null; 441 | } 442 | const dataPoint = param.seriesData.get(series); 443 | return dataPoint || null; 444 | } 445 | 446 | function syncCrosshair(chart, series, dataPoint) { 447 | if (dataPoint) { 448 | chart.setCrosshairPosition(dataPoint.value, dataPoint.time, series); 449 | return; 450 | } 451 | chart.clearCrosshairPosition(); 452 | } 453 | 454 | chart.subscribeCrosshairMove(param => { 455 | const dataPoint = getCrosshairDataPoint(candlestickSeries, param); 456 | syncCrosshair(rsiChart, rsiLine, dataPoint); 457 | }); 458 | 459 | rsiChart.subscribeCrosshairMove(param => { 460 | const dataPoint = getCrosshairDataPoint(rsiLine, param); 461 | syncCrosshair(chart, candlestickSeries, dataPoint); 462 | }); 463 | 464 | // Add a new symbol to the watchlist 465 | function addSymbol() { 466 | const symbolInput = document.getElementById('newSymbol'); 467 | const symbolError = document.getElementById('symbolError'); 468 | const symbol = symbolInput.value.trim().toUpperCase(); 469 | 470 | // Clear previous error 471 | symbolError.classList.add('hidden'); 472 | symbolError.textContent = ''; 473 | 474 | if (!symbol) { 475 | symbolError.textContent = 'Please enter a symbol'; 476 | symbolError.classList.remove('hidden'); 477 | return; 478 | } 479 | 480 | // Show loading state 481 | const addBtn = document.getElementById('addSymbolBtn'); 482 | const originalContent = addBtn.innerHTML; 483 | addBtn.innerHTML = ''; 484 | addBtn.disabled = true; 485 | 486 | // Send request to add symbol 487 | fetch('/api/symbols', { 488 | method: 'POST', 489 | headers: { 490 | 'Content-Type': 'application/json', 491 | }, 492 | body: JSON.stringify({ symbol: symbol }), 493 | }) 494 | .then(response => response.json()) 495 | .then(data => { 496 | if (data.error) { 497 | symbolError.textContent = data.error; 498 | symbolError.classList.remove('hidden'); 499 | } else { 500 | // Clear input 501 | symbolInput.value = ''; 502 | 503 | // Add new symbol to the watchlist without full refresh 504 | // Only fetch the individual symbol data 505 | refreshSymbolQuote(data.symbol.ticker); 506 | } 507 | }) 508 | .catch(error => { 509 | console.error('Error adding symbol:', error); 510 | symbolError.textContent = 'Error adding symbol. Please try again.'; 511 | symbolError.classList.remove('hidden'); 512 | }) 513 | .finally(() => { 514 | // Restore button 515 | addBtn.innerHTML = originalContent; 516 | addBtn.disabled = false; 517 | }); 518 | } 519 | 520 | // Fetch individual symbol quote 521 | function refreshSymbolQuote(symbol) { 522 | // This is just a partial refresh for a single symbol 523 | // For now, just reload the full watchlist since we need to implement 524 | // a new API endpoint for getting a single symbol's data 525 | loadWatchlist(); 526 | } 527 | 528 | // Remove a symbol from the watchlist 529 | function removeSymbol(symbolId) { 530 | // Find and remove the element from the DOM directly for immediate feedback 531 | const symbolElement = document.querySelector(`.delete-symbol[data-id="${symbolId}"]`).closest('.card'); 532 | if (symbolElement) { 533 | symbolElement.classList.add('animate-fade-out'); 534 | setTimeout(() => { 535 | symbolElement.style.height = symbolElement.offsetHeight + 'px'; 536 | setTimeout(() => { 537 | symbolElement.style.height = '0'; 538 | symbolElement.style.opacity = '0'; 539 | symbolElement.style.margin = '0'; 540 | symbolElement.style.padding = '0'; 541 | symbolElement.style.overflow = 'hidden'; 542 | setTimeout(() => symbolElement.remove(), 300); 543 | }, 10); 544 | }, 100); 545 | } 546 | 547 | // Also remove from database 548 | fetch(`/api/symbols/${symbolId}`, { 549 | method: 'DELETE', 550 | }) 551 | .then(response => { 552 | if (!response.ok) { 553 | console.error('Error removing symbol:', response.statusText); 554 | // If failed, reload the watchlist to restore the removed item 555 | loadWatchlist(); 556 | } 557 | }) 558 | .catch(error => { 559 | console.error('Error removing symbol:', error); 560 | // If failed, reload the watchlist to restore the removed item 561 | loadWatchlist(); 562 | }); 563 | } 564 | --------------------------------------------------------------------------------