Spaces:
Running
Running
| class StockChartCard extends HTMLElement { | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.chart = null; | |
| this.candlestickSeries = null; | |
| this.volumeSeries = null; | |
| this.vwapSeries = null; | |
| this.resizeObserver = null; | |
| this.intersectionObserver = null; | |
| this.isLoaded = false; | |
| this.latestCandle = null; | |
| this.latestVolume = null; | |
| this.isExpanded = false; | |
| this.chartGeneration = 0; | |
| this.fetchRequestId = 0; | |
| } | |
| expand() { | |
| if (this.isExpanded) { | |
| this.collapse(); | |
| } else { | |
| this.isExpanded = true; | |
| this.classList.add("expanded"); | |
| this._handleEsc = (e) => { | |
| if (e.key === "Escape") this.collapse(); | |
| }; | |
| document.addEventListener("keydown", this._handleEsc); | |
| setTimeout(() => this.reinitializeChart?.(), 100); | |
| } | |
| } | |
| collapse() { | |
| this.isExpanded = false; | |
| this.classList.remove("expanded"); | |
| if (this._handleEsc) { | |
| document.removeEventListener("keydown", this._handleEsc); | |
| this._handleEsc = null; | |
| } | |
| setTimeout(() => this.reinitializeChart?.(), 100); | |
| } | |
| static get observedAttributes() { | |
| return [ | |
| "symbol", | |
| "timeframe", | |
| "source", | |
| "show-actions", | |
| "markers", | |
| "context", | |
| "signal-date", | |
| "date-range", | |
| "compact", | |
| ]; | |
| } | |
| connectedCallback() { | |
| this.render(); | |
| // Ensure LightweightCharts is loaded before initChart | |
| if (typeof LightweightCharts === "undefined") { | |
| console.error("LightweightCharts not loaded, waiting..."); | |
| // Wait for LightweightCharts to load | |
| const checkAndInit = () => { | |
| if (typeof LightweightCharts !== "undefined") { | |
| this.initChart(); | |
| this._doPostInit(); | |
| } else { | |
| setTimeout(checkAndInit, 50); | |
| } | |
| }; | |
| checkAndInit(); | |
| } else { | |
| this.initChart(); | |
| this._doPostInit(); | |
| } | |
| } | |
| _doPostInit() { | |
| // Use setTimeout to allow element to be connected to DOM before checking | |
| // This handles the case where element is created via innerHTML before being appended | |
| setTimeout(() => { | |
| const isInModal = this.closest("#analyze-modal") !== null; | |
| if (isInModal) { | |
| this.isLoaded = true; | |
| this.fetchData(); | |
| } else { | |
| this.setupLazyLoading(); | |
| } | |
| }, 0); | |
| } | |
| attributeChangedCallback(name, oldVal, newVal) { | |
| if (oldVal === newVal) return; | |
| if (name === "timeframe" || name === "symbol") { | |
| // Always recreate chart when timeframe changes to ensure VWAP series is correct | |
| if (name === "timeframe" && this.isLoaded && oldVal && newVal) { | |
| this.destroyChart(); | |
| this.initChart(); | |
| } | |
| if (this.isLoaded) this.fetchData(); | |
| } else if (name === "markers") { | |
| this.updateMarkers(); | |
| } else if (name === "context") { | |
| // No direct action, used in fetchData | |
| } else if (name === "compact") { | |
| this.destroyChart(); | |
| this.render(); | |
| this.initChart(); | |
| if (this.isLoaded) this.fetchData(); | |
| } else if (name === "date-range") { | |
| if (this.isLoaded) this.fetchData(); | |
| } else { | |
| this.render(); | |
| } | |
| } | |
| setupLazyLoading() { | |
| this.intersectionObserver = new IntersectionObserver( | |
| (entries) => { | |
| if (entries[0].isIntersecting && !this.isLoaded) { | |
| this.isLoaded = true; | |
| this.fetchData(); | |
| } | |
| }, | |
| { rootMargin: "200px" }, | |
| ); | |
| this.intersectionObserver.observe(this); | |
| } | |
| render() { | |
| const symbol = this.getAttribute("symbol") || "--"; | |
| const timeframe = | |
| this.getAttribute("timeframe") || | |
| localStorage.getItem(`tf-${symbol}`) || | |
| "1D"; | |
| const showActions = this.hasAttribute("show-actions"); | |
| const compact = this.hasAttribute("compact"); | |
| if (compact) { | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| background: #000; | |
| min-height: 240px; | |
| } | |
| .body { | |
| padding: 0; | |
| position: relative; | |
| } | |
| .chart-container { | |
| height: 240px; | |
| width: 100%; | |
| background: #000; | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 12px; | |
| left: 12px; | |
| z-index: 10; | |
| pointer-events: none; | |
| background: rgba(10, 10, 10, 0.85); | |
| backdrop-filter: blur(4px); | |
| padding: 3px 7px; | |
| border-radius: 4px; | |
| border: 1px solid #222; | |
| font-family: system-ui, sans-serif; | |
| font-size: 0.65rem; | |
| color: #666; | |
| } | |
| .expand-btn { | |
| position: absolute; | |
| top: 4px; | |
| right: 4px; | |
| background: rgba(10, 10, 10, 0.7); | |
| border: 1px solid #222; | |
| color: #666; | |
| padding: 2px 5px; | |
| font-size: 0.65rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| z-index: 20; | |
| } | |
| .expand-btn:hover { | |
| background: #fff; | |
| color: #000; | |
| } | |
| :host(.expanded) { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| z-index: 1000; | |
| margin: 0; | |
| background: #000; | |
| } | |
| :host(.expanded) .chart-container { | |
| height: 100vh !important; | |
| } | |
| </style> | |
| <div class="body"> | |
| <button class="expand-btn" onclick="this.getRootNode().host.expand()">⛶</button> | |
| <div class="overlay" id="ohlcv">Loading data...</div> | |
| <div class="chart-container" id="chart"></div> | |
| </div> | |
| `; | |
| return; | |
| } | |
| this.shadowRoot.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| background: var(--bg-elevated, #0a0a0a); | |
| border: 1px solid var(--border, #222222); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| min-height: 340px; | |
| } | |
| .header { | |
| padding: var(--space-sm, 8px) var(--space-md, 16px); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid var(--border, #222222); | |
| } | |
| .symbol { | |
| color: var(--accent, #ffffff); | |
| font-size: var(--font-lg, 1rem); | |
| font-weight: 700; | |
| white-space: nowrap; | |
| cursor: pointer; | |
| transition: opacity 0.15s; | |
| } | |
| .symbol:hover { | |
| opacity: 0.8; | |
| } | |
| .date { | |
| font-size: var(--font-xs, 0.65rem); | |
| color: var(--text-muted, #666666); | |
| font-weight: 600; | |
| } | |
| .body { | |
| padding: var(--space-sm, 8px); | |
| position: relative; | |
| } | |
| .chart-container { | |
| height: 240px; | |
| width: 100%; | |
| background: #000; | |
| border-radius: 4px; | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 12px; | |
| left: 12px; | |
| z-index: 10; | |
| pointer-events: none; | |
| background: rgba(10, 10, 10, 0.85); | |
| backdrop-filter: blur(4px); | |
| padding: 3px 7px; | |
| border-radius: 4px; | |
| border: 1px solid var(--border, #222222); | |
| font-family: system-ui, sans-serif; | |
| font-size: 0.65rem; | |
| color: var(--text-muted, #666666); | |
| } | |
| .footer { | |
| padding: var(--space-sm, 8px) var(--space-md, 16px); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-top: 1px solid var(--border, #222222); | |
| } | |
| .metrics { | |
| display: flex; | |
| gap: var(--space-md, 16px); | |
| align-items: center; | |
| } | |
| .price { | |
| font-weight: 600; | |
| font-size: var(--font-md, 0.875rem); | |
| color: var(--text, #ffffff); | |
| } | |
| .change { | |
| font-weight: 600; | |
| font-size: var(--font-sm, 0.75rem); | |
| } | |
| .volume { | |
| font-weight: 600; | |
| font-size: var(--font-xs, 0.65rem); | |
| color: var(--text-muted, #666666); | |
| } | |
| .positive { color: #089981; } | |
| .negative { color: #f23645; } | |
| .date-left { | |
| font-size: var(--font-xs, 0.65rem); | |
| color: var(--text-muted, #666666); | |
| font-weight: 600; | |
| } | |
| .tf-selector { | |
| display: flex; | |
| gap: 4px; | |
| background: var(--bg, #000000); | |
| padding: 2px; | |
| border-radius: 4px; | |
| } | |
| .tf-btn { | |
| background: transparent; | |
| border: none; | |
| color: var(--text-muted, #666666); | |
| padding: 3px 7px; | |
| font-size: 0.65rem; | |
| font-weight: 600; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| } | |
| .tf-btn.active { | |
| background: var(--accent, #ffffff); | |
| color: #000; | |
| } | |
| .data-value.positive { color: #089981; } | |
| .data-value.negative { color: #f23645; } | |
| .badge-high { background: #f23645; color: #fff; } | |
| .badge-low { background: #089981; color: #fff; } | |
| .badge-bullish { background: #089981; color: #fff; } | |
| .badge-bearish { background: #f23645; color: #fff; } | |
| .badge-neutral { background: #666; color: #fff; } | |
| .filing-item { | |
| padding: 6px 0; | |
| border-bottom: 1px solid var(--border, #222); | |
| font-size: 0.65rem; | |
| } | |
| .filing-form { | |
| font-weight: 700; | |
| color: var(--accent, #fff); | |
| margin-right: 6px; | |
| } | |
| .filing-date { | |
| color: var(--text-muted, #888); | |
| margin-right: 6px; | |
| } | |
| .expand-btn { | |
| background: transparent; | |
| border: none; | |
| color: var(--text-muted, #666); | |
| padding: 2px 6px; | |
| font-size: 0.65rem; | |
| font-weight: 600; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| line-height: 1; | |
| } | |
| .expand-btn:hover { | |
| background: var(--accent, #fff); | |
| color: #000; | |
| } | |
| :host(.expanded) { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| z-index: 1000; | |
| margin: 0; | |
| background: #000; | |
| } | |
| :host(.expanded) .chart-container { | |
| height: 100vh !important; | |
| } | |
| </style> | |
| <div class="header"> | |
| <span class="symbol" onclick="openAnalyzeModalFor('${symbol}')">${symbol}</span> | |
| <div style="display: flex; align-items: center; gap: 8px;"> | |
| <button class="expand-btn" onclick="this.getRootNode().host.expand()">⛶</button> | |
| <div class="tf-selector"> | |
| ${["1D", "1Y", "1H", "24H", "30M", "15M", "5M", "1M"] | |
| .map( | |
| (tf) => ` | |
| <button class="tf-btn ${tf === timeframe ? "active" : ""}" data-tf="${tf}">${tf}</button> | |
| `, | |
| ) | |
| .join("")} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="body"> | |
| <div class="overlay" id="ohlcv">Loading data...</div> | |
| <div class="chart-container" id="chart"></div> | |
| </div> | |
| <div class="footer"> | |
| <div class="metrics"> | |
| <span class="date-left" id="date">--</span> | |
| <span class="price" id="price">--</span> | |
| <span class="change" id="change">--</span> | |
| <span class="volume" id="volume">--</span> | |
| </div> | |
| ${showActions ? `<div class="actions"><slot name="actions"></slot></div>` : ""} | |
| </div> | |
| `; | |
| this.shadowRoot.querySelectorAll(".tf-btn").forEach((btn) => { | |
| btn.onclick = (e) => { | |
| e.stopPropagation(); | |
| const tf = btn.dataset.tf; | |
| this.setAttribute("timeframe", tf); | |
| localStorage.setItem(`tf-${symbol}`, tf); | |
| this.updateActiveTf(tf); | |
| }; | |
| }); | |
| } | |
| updateActiveTf(tf) { | |
| this.shadowRoot.querySelectorAll(".tf-btn").forEach((btn) => { | |
| btn.classList.toggle("active", btn.dataset.tf === tf); | |
| }); | |
| } | |
| destroyChart() { | |
| this.fetchRequestId += 1; | |
| if (this.resizeObserver) { | |
| this.resizeObserver.disconnect(); | |
| this.resizeObserver = null; | |
| } | |
| if (this.chart) { | |
| this.chart.remove(); | |
| this.chart = null; | |
| this.candlestickSeries = null; | |
| this.volumeSeries = null; | |
| this.vwapSeries = null; | |
| } | |
| } | |
| initChart() { | |
| const container = this.shadowRoot.getElementById("chart"); | |
| const symbol = this.getAttribute("symbol"); | |
| const timeframe = | |
| this.getAttribute("timeframe") || | |
| localStorage.getItem(`tf-${symbol}`) || | |
| "1D"; | |
| // Clear container and reset resize observer | |
| container.innerHTML = ""; | |
| if (this.resizeObserver) { | |
| this.resizeObserver.disconnect(); | |
| this.resizeObserver = null; | |
| } | |
| this.chart = LightweightCharts.createChart(container, { | |
| autoSize: true, | |
| layout: { | |
| background: { color: "#000000" }, | |
| textColor: "#666666", | |
| fontSize: 9, | |
| }, | |
| grid: { | |
| vertLines: { color: "#222222" }, | |
| horzLines: { color: "#222222" }, | |
| }, | |
| crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, | |
| rightPriceScale: { | |
| borderColor: "#222222", | |
| autoScale: true, | |
| scaleMargins: { top: 0.1, bottom: 0.2 }, | |
| }, | |
| timeScale: { | |
| borderColor: "#222222", | |
| timeVisible: true, | |
| barSpacing: 4, | |
| timeZone: "America/New_York", | |
| }, | |
| }); | |
| this.candlestickSeries = this.chart.addCandlestickSeries({ | |
| upColor: "#089981", | |
| downColor: "#f23645", | |
| borderUpColor: "#089981", | |
| borderDownColor: "#f23645", | |
| wickUpColor: "#089981", | |
| wickDownColor: "#f23645", | |
| }); | |
| this.volumeSeries = this.chart.addHistogramSeries({ | |
| color: "#089981", | |
| priceFormat: { type: "volume" }, | |
| priceScaleId: "", | |
| }); | |
| this.volumeSeries | |
| .priceScale() | |
| .applyOptions({ scaleMargins: { top: 0.75, bottom: 0 } }); | |
| // Add VWAP line series for intraday timeframes | |
| const isIntraday = !["1D", "1Y"].includes(timeframe); | |
| if (isIntraday) { | |
| this.vwapSeries = this.chart.addLineSeries({ | |
| color: "#FFFF00", | |
| lineWidth: 1, | |
| crosshairMarkerVisible: false, | |
| lastValueVisible: true, | |
| lastValueColor: "#FFFF00", | |
| priceLineVisible: true, | |
| priceLineColor: "#FFFF00", | |
| priceLineWidth: 1, | |
| priceLineStyle: 2, | |
| title: "VWAP", | |
| }); | |
| } | |
| this.chart.subscribeCrosshairMove((param) => { | |
| if (param.time && param.seriesData) { | |
| const candle = param.seriesData.get(this.candlestickSeries); | |
| const volume = param.seriesData.get(this.volumeSeries); | |
| this.updateOhlcvOverlay(candle, volume?.value); | |
| } else { | |
| this.updateOhlcvOverlay(this.latestCandle, this.latestVolume); | |
| } | |
| }); | |
| this.resizeObserver = new ResizeObserver((entries) => { | |
| if (entries[0] && this.chart) { | |
| this.chart.applyOptions({ width: entries[0].contentRect.width }); | |
| } | |
| }); | |
| this.resizeObserver.observe(container); | |
| this.chartGeneration += 1; | |
| } | |
| updateOhlcvOverlay(candle, volume) { | |
| const ohlcvEl = this.shadowRoot.getElementById("ohlcv"); | |
| if (candle) { | |
| const volStr = window.FinancialAPI | |
| ? FinancialAPI.formatVolume(volume) | |
| : volume; | |
| // Show NY time in the overlay for intraday | |
| const timeStr = | |
| window.FinancialAPI && candle.time && typeof candle.time === "number" | |
| ? FinancialAPI.formatNyTime(candle.time) | |
| : ""; | |
| ohlcvEl.textContent = timeStr | |
| ? `${timeStr} | O: ${candle.open.toFixed(2)} H: ${candle.high.toFixed(2)} L: ${candle.low.toFixed(2)} C: ${candle.close.toFixed(2)} V: ${volStr}` | |
| : `O: ${candle.open.toFixed(2)} H: ${candle.high.toFixed(2)} L: ${candle.low.toFixed(2)} C: ${candle.close.toFixed(2)} V: ${volStr}`; | |
| } | |
| } | |
| reinitializeChart() { | |
| // Destroy and recreate chart with correct dimensions | |
| this.destroyChart(); | |
| this.initChart(); | |
| // Fetch data again | |
| this.isLoaded = true; | |
| this.fetchData(); | |
| } | |
| updateMarkers() { | |
| if (!this.candlestickSeries) return; | |
| const markersAttr = this.getAttribute("markers"); | |
| try { | |
| const markers = JSON.parse(markersAttr || "[]"); | |
| this.candlestickSeries.setMarkers(markers); | |
| } catch (e) { | |
| console.warn("Invalid markers JSON", markersAttr); | |
| } | |
| } | |
| async addReverseSplitMarkers(candles, candlestickSeries) { | |
| const symbol = this.getAttribute("symbol"); | |
| if (!symbol || !candlestickSeries) return; | |
| const isInModal = this.closest("#analyze-modal") !== null; | |
| const chartGeneration = this.chartGeneration; | |
| try { | |
| // Fetch reverse splits for this symbol (last 90 days) | |
| // Use batch method if available (for watchlist), otherwise single fetch | |
| let reverseSplits; | |
| if (window.FinancialAPI && FinancialAPI.fetchReverseSplitsBatch && !isInModal) { | |
| // Batch fetch will use cache if available | |
| const allSplits = await FinancialAPI.fetchReverseSplitsBatch( | |
| [symbol], | |
| 90, | |
| ); | |
| reverseSplits = allSplits.filter( | |
| (rs) => rs.symbol === symbol.toUpperCase(), | |
| ); | |
| } else { | |
| reverseSplits = await FinancialAPI.fetchReverseSplits(symbol, 90, { | |
| forceFresh: isInModal, | |
| }); | |
| } | |
| if (!reverseSplits || reverseSplits.length === 0) return; | |
| // Get existing markers from attribute | |
| const existingMarkers = JSON.parse(this.getAttribute("markers") || "[]"); | |
| // Create markers for reverse splits - use price axis markers | |
| const rsMarkers = reverseSplits.map((rs) => { | |
| // Find the candle closest to the reverse split date | |
| const rsTime = new Date(rs.ex_date + "T09:30:00").getTime() / 1000; | |
| // Find the candle on that date | |
| const closestCandle = candles.find((c) => { | |
| const candleTime = | |
| typeof c.time === "number" | |
| ? c.time | |
| : new Date(c.time).getTime() / 1000; | |
| return candleTime >= rsTime && candleTime < rsTime + 86400; | |
| }); | |
| // Position at the low of the day for visibility | |
| const price = closestCandle ? closestCandle.low : 0; | |
| return { | |
| time: rsTime, | |
| position: "inBar", | |
| color: "#FF4444", | |
| shape: "circle", | |
| text: `RS ${rs.ratio}`, | |
| size: 0.5, | |
| }; | |
| }); | |
| // Combine existing markers with reverse split markers | |
| const allMarkers = [...existingMarkers, ...rsMarkers]; | |
| if ( | |
| chartGeneration !== this.chartGeneration || | |
| !this.candlestickSeries || | |
| candlestickSeries !== this.candlestickSeries | |
| ) { | |
| return; | |
| } | |
| this.candlestickSeries.setMarkers(allMarkers); | |
| } catch (e) { | |
| console.warn("Error fetching reverse splits:", e); | |
| } | |
| } | |
| async fetchData() { | |
| const symbol = this.getAttribute("symbol"); | |
| const timeframe = | |
| this.getAttribute("timeframe") || | |
| localStorage.getItem(`tf-${symbol}`) || | |
| "1D"; | |
| const context = this.getAttribute("context") || "default"; | |
| const dateRange = this.getAttribute("date-range"); | |
| if (!symbol) return; | |
| const requestId = ++this.fetchRequestId; | |
| // Guard: ensure chart is initialized before fetching data | |
| // Save references now, then verify they are still current after awaits. | |
| const candlestickSeries = this.candlestickSeries; | |
| const volumeSeries = this.volumeSeries; | |
| const vwapSeries = this.vwapSeries; | |
| const chart = this.chart; | |
| const chartGeneration = this.chartGeneration; | |
| if (!candlestickSeries || !chart) { | |
| console.warn("fetchData: chart not initialized, retrying in 100ms", { | |
| hasChart: !!chart, | |
| hasCandle: !!candlestickSeries, | |
| symbol, | |
| }); | |
| setTimeout(() => this.fetchData(), 100); | |
| return; | |
| } | |
| const isStale = () => | |
| requestId !== this.fetchRequestId || | |
| chartGeneration !== this.chartGeneration || | |
| chart !== this.chart || | |
| candlestickSeries !== this.candlestickSeries || | |
| volumeSeries !== this.volumeSeries || | |
| vwapSeries !== this.vwapSeries; | |
| try { | |
| let result; | |
| if (dateRange && context === "scanner") { | |
| const [start, end] = dateRange.split("|"); | |
| const response = await fetch( | |
| `/api/scan?symbols=${symbol}&timeframe=${timeframe}&start=${start}&end=${end}`, | |
| ); | |
| const scanResult = await response.json(); | |
| const item = scanResult.results?.find((r) => r.symbol === symbol); | |
| if (!item || !item.daily_bars?.length) { | |
| const ohlcvEl = this.shadowRoot.getElementById("ohlcv"); | |
| if (ohlcvEl) ohlcvEl.textContent = "No data available"; | |
| return; | |
| } | |
| result = FinancialAPI.mapToYahooFormat(item.daily_bars); | |
| } else { | |
| result = await FinancialAPI.fetchChart(symbol, timeframe, context); | |
| } | |
| if (isStale()) return; | |
| const { candles, volumes, vwap } = FinancialAPI.processChartData( | |
| result, | |
| timeframe, | |
| ); | |
| // Filter candles by date-range if provided | |
| let filteredCandles = candles; | |
| let filteredVolumes = volumes; | |
| if (dateRange && candles?.length) { | |
| const [startStr, endStr] = dateRange.split("|"); | |
| filteredCandles = candles.filter((c) => { | |
| const t = typeof c.time === "number" ? c.time : new Date(c.time + "T00:00:00Z").getTime() / 1000; | |
| const start = Math.floor(new Date(startStr + "T00:00:00Z").getTime() / 1000); | |
| const end = Math.floor(new Date(endStr + "T23:59:59Z").getTime() / 1000); | |
| return t >= start && t <= end; | |
| }); | |
| filteredVolumes = volumes.filter((v) => { | |
| const t = typeof v.time === "number" ? v.time : new Date(v.time + "T00:00:00Z").getTime() / 1000; | |
| const start = Math.floor(new Date(startStr + "T00:00:00Z").getTime() / 1000); | |
| const end = Math.floor(new Date(endStr + "T23:59:59Z").getTime() / 1000); | |
| return t >= start && t <= end; | |
| }); | |
| } | |
| // Handle empty data gracefully | |
| if (!filteredCandles || filteredCandles.length === 0) { | |
| const ohlcvEl = this.shadowRoot.getElementById("ohlcv"); | |
| if (ohlcvEl) ohlcvEl.textContent = "No data available"; | |
| return; | |
| } | |
| if (isStale()) return; | |
| candlestickSeries.setData(filteredCandles); | |
| volumeSeries.setData(filteredVolumes); | |
| if (isStale()) return; | |
| if (vwapSeries && vwap && vwap.length > 0) { | |
| vwapSeries.setData(vwap); | |
| } | |
| // Set visible range to show all data | |
| if (filteredCandles.length > 0) { | |
| const firstTime = typeof filteredCandles[0].time === "number" | |
| ? filteredCandles[0].time | |
| : new Date(filteredCandles[0].time).getTime() / 1000; | |
| const lastTime = typeof filteredCandles[filteredCandles.length - 1].time === "number" | |
| ? filteredCandles[filteredCandles.length - 1].time | |
| : new Date(filteredCandles[filteredCandles.length - 1].time).getTime() / 1000; | |
| chart.timeScale().setVisibleRange({ | |
| from: firstTime, | |
| to: lastTime, | |
| }); | |
| } | |
| // Fetch and add reverse split markers (non-blocking) | |
| this.addReverseSplitMarkers(filteredCandles, candlestickSeries); | |
| // Handle zoom and centering based on timeframe and signal | |
| const signalDate = this.getAttribute("signal-date"); | |
| const barsToShow = | |
| timeframe === "1D" ? 40 : timeframe === "1Y" ? 252 : timeframe === "24H" ? 10 : 90; | |
| if (signalDate) { | |
| // Find the signal bar index | |
| const signalTime = new Date(signalDate + "T00:00:00Z").getTime() / 1000; | |
| const signalIdx = filteredCandles.findIndex((c) => { | |
| const candleTime = | |
| typeof c.time === "number" | |
| ? c.time | |
| : new Date(c.time).getTime() / 1000; | |
| return candleTime >= signalTime && candleTime < signalTime + 86400; | |
| }); | |
| if (signalIdx !== -1) { | |
| // Center on signal, show barsToShow bars around it | |
| const startIdx = Math.max(0, signalIdx - Math.floor(barsToShow / 2)); | |
| const endIdx = Math.min( | |
| filteredCandles.length, | |
| signalIdx + Math.ceil(barsToShow / 2) + 1, | |
| ); | |
| if (filteredCandles[startIdx] && filteredCandles[endIdx - 1]) { | |
| const startTime = | |
| typeof filteredCandles[startIdx].time === "number" | |
| ? filteredCandles[startIdx].time | |
| : new Date(filteredCandles[startIdx].time).getTime() / 1000; | |
| const endTime = | |
| typeof filteredCandles[endIdx - 1].time === "number" | |
| ? filteredCandles[endIdx - 1].time | |
| : new Date(filteredCandles[endIdx - 1].time).getTime() / 1000; | |
| chart.timeScale().setVisibleRange({ | |
| from: startTime, | |
| to: endTime, | |
| }); | |
| } | |
| } else { | |
| // Signal not in data, show last barsToShow | |
| chart.timeScale().setVisibleRange({ | |
| from: filteredCandles[filteredCandles.length - barsToShow]?.time, | |
| to: filteredCandles[filteredCandles.length - 1]?.time, | |
| }); | |
| } | |
| } else { | |
| // Default: show last barsToShow | |
| const startIdx = Math.max(0, filteredCandles.length - barsToShow); | |
| chart.timeScale().setVisibleRange({ | |
| from: filteredCandles[startIdx].time, | |
| to: filteredCandles[filteredCandles.length - 1].time, | |
| }); | |
| } | |
| this.updateMarkers(); | |
| if (filteredCandles.length > 0) { | |
| this.latestCandle = filteredCandles[filteredCandles.length - 1]; | |
| this.latestVolume = | |
| filteredVolumes.length > 0 ? filteredVolumes[filteredVolumes.length - 1].value : 0; | |
| // Calculate change: current close vs previous candle close (standard day-over-day) | |
| // Use the second-to-last candle from filtered data if available, otherwise use first | |
| let change = 0; | |
| if (filteredCandles.length >= 2) { | |
| const prevCandle = filteredCandles[filteredCandles.length - 2]; | |
| change = ((this.latestCandle.close - prevCandle.close) / prevCandle.close) * 100; | |
| } | |
| const priceEl = this.shadowRoot.getElementById("price"); | |
| if (priceEl) priceEl.textContent = `$${this.latestCandle.close.toFixed(2)}`; | |
| const changeEl = this.shadowRoot.getElementById("change"); | |
| if (changeEl) { | |
| changeEl.textContent = `${change >= 0 ? "+" : ""}${change.toFixed(2)}%`; | |
| changeEl.className = `change ${change >= 0 ? "positive" : "negative"}`; | |
| } | |
| if (this.latestVolume) { | |
| const volEl = this.shadowRoot.getElementById("volume"); | |
| if (volEl) volEl.textContent = FinancialAPI.formatVolume(this.latestVolume); | |
| } | |
| const signalDate = this.getAttribute("signal-date"); | |
| const dateEl = this.shadowRoot.getElementById("date"); | |
| if (dateEl) { | |
| if (signalDate) { | |
| dateEl.textContent = signalDate; | |
| } else { | |
| const timeStr = | |
| typeof this.latestCandle.time === "number" | |
| ? new Date(this.latestCandle.time * 1000).toLocaleString() | |
| : this.latestCandle.time; | |
| dateEl.textContent = timeStr; | |
| } | |
| } | |
| this.updateOhlcvOverlay(this.latestCandle, this.latestVolume); | |
| } | |
| } catch (e) { | |
| console.error("Fetch error:", e); | |
| const ohlcvEl = this.shadowRoot.getElementById("ohlcv"); | |
| if (ohlcvEl) ohlcvEl.textContent = "Error loading data"; | |
| } | |
| } | |
| disconnectedCallback() { | |
| if (this.intersectionObserver) this.intersectionObserver.disconnect(); | |
| this.destroyChart(); | |
| } | |
| } | |
| customElements.define("stock-chart-card", StockChartCard); | |