UNPKG

stock-chart-display

Version:

Lit Web Component to display stock charts

491 lines (459 loc) 14.9 kB
import { LitElement, html } from "lit"; import Chart from "chart.js/auto"; import zoomPlugin from "chartjs-plugin-zoom"; import { customElement, property } from "lit/decorators.js"; interface StockData { volume?: number; open?: number; high?: number; low?: number; date: string; close: number; MA20?: number; MA5?: number; MA10?: number; MA50?: number; MA200?: number; MA100?: number; RSI?: number; MACDLine?: number; SignalLine?: any; MACDHistogram?: any; } @customElement("stock-chart-display") class ChartElement extends LitElement { @property({ type: Array }) stockData: StockData[] = []; chart: Chart | null = null; constructor() { super(); this.stockData = []; } // First updated is called after the element has been rendered into the DOM firstUpdated() { this._createChart(); } _resetZoom() { this.chart?.resetZoom(); } async willUpdate(changedProperties: { has: (arg0: string) => any }) { if (changedProperties.has("stockData")) { if (!this.chart) { this._createChart(); // Ensure the chart is created } else { this._updateChart(); // Update the chart if it exists } } } // Define the custom plugin customLinePlugin = { id: "customLinePlugin", afterDraw: (chart) => { const ctx = chart.ctx; const { chartArea, scales, data } = chart; const rsiDataset = chart.data.datasets.find((ds) => ds.label === "RSI"); const closeDataset = data.datasets.find( (ds) => ds.label === "Close Price" ); const ma200Dataset = data.datasets.find((ds) => ds.label === "MA200"); if (!rsiDataset || !closeDataset || !ma200Dataset) return; const rsiData = rsiDataset.data as number[]; const closeData = closeDataset.data as number[]; const ma200Data = ma200Dataset.data as number[]; const RISK_PERCENT = 0.01; const REWARD_RATIO = 2; for (let i = 1; i < closeData.length; i++) { //base on rsi const rsiValue = rsiData[i]; if (rsiValue !== null && rsiValue < 30) { const x = scales.x.getPixelForValue(i); const y = scales.yRSI.getPixelForValue(rsiValue); ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.fillStyle = "green"; ctx.fill(); ctx.fillStyle = "green"; ctx.font = "bold 12px sans-serif"; ctx.fillText("Buy", x + 6, y - 6); } else if (rsiValue !== null && rsiValue > 70) { const x = scales.x.getPixelForValue(i); const y = scales.yRSI.getPixelForValue(rsiValue); ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.fillStyle = "red"; ctx.fill(); ctx.fillStyle = "red"; ctx.font = "bold 12px sans-serif"; ctx.fillText("Sell", x + 6, y - 6); } // Add MA200 bullish/bearish crossover signals const prevClose = closeData[i - 1]; const prevMA200 = ma200Data[i - 1]; const currClose = closeData[i]; const currMA200 = ma200Data[i]; if ( prevClose == null || prevMA200 == null || currClose == null || currMA200 == null ) continue; const x = scales.x.getPixelForValue(i); const y = scales.yPrice.getPixelForValue(currClose); if (currClose > currMA200 && prevClose < prevMA200) { ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.fillStyle = "green"; ctx.fill(); ctx.fillStyle = "green"; ctx.font = "bold 12px sans-serif"; ctx.fillText("Bull", x + 6, y - 6); const stop = currClose * (1 - RISK_PERCENT); const target = currClose + (currClose - stop) * REWARD_RATIO; const yStop = scales.yPrice.getPixelForValue(stop); const yTarget = scales.yPrice.getPixelForValue(target); // Stop-Loss (SL) line ctx.fillStyle = "red"; ctx.fillRect(x, yStop, 100, 1); // 100px wide, 1px high ctx.font = "bold 12px sans-serif"; ctx.fillText("SL " + stop.toFixed(2), x + 12, yStop - 4); // Take-Profit (TP) line ctx.fillStyle = "green"; ctx.fillRect(x, yTarget, 100, 1); // 100px wide, 1px high ctx.fillText("TP " + target.toFixed(2), x + 12, yTarget - 4); } else if (currClose < currMA200 && prevClose > prevMA200) { ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.fillStyle = "purple"; ctx.fill(); ctx.fillStyle = "purple"; ctx.font = "bold 12px sans-serif"; ctx.fillText("Bear", x + 6, y - 6); const stop = currClose * (1 + RISK_PERCENT); const target = currClose - (stop - currClose) * REWARD_RATIO; const yStop = scales.yPrice.getPixelForValue(stop); const yTarget = scales.yPrice.getPixelForValue(target); // // Stop-Loss (SL) line // ctx.fillStyle = "purple"; // ctx.fillRect(x, yStop, 100, 1); // 100px wide, 1px high // ctx.font = "bold 12px sans-serif"; // ctx.fillText("BEAR-SL", x + 12, yStop - 4); // // Take-Profit (TP) line // ctx.fillStyle = "purple"; // ctx.fillRect(x, yTarget, 100, 1); // 100px wide, 1px high // ctx.fillText("BEAR-TP", x + 12, yTarget - 4); } ctx.setLineDash([]); } }, }; _getChartData() { const data = { dates: this.stockData.map((data) => data.date), closePrices: this.stockData.map((data) => data.close), ma5: this.stockData.map((data) => data.MA5 ?? null), ma10: this.stockData.map((data) => data.MA10 ?? null), ma20: this.stockData.map((data) => data.MA20 ?? null), ma50: this.stockData.map((data) => data.MA50 ?? null), ma100: this.stockData.map((data) => data.MA100 ?? null), ma200: this.stockData.map((data) => data.MA200 ?? null), RSI: this.stockData.map((data) => data.RSI ?? null), MACDLine: this.stockData.map((data) => data.MACDLine ?? null), SignalLine: this.stockData.map((data) => data.SignalLine ?? null), MACDHistogram: this.stockData.map((data) => data.MACDHistogram ?? null), volumes: this.stockData.map((data) => data.volume ?? 0), // Volume data }; return data; } _updateChart() { if (this.chart) { const { dates, closePrices, ma5, ma10, ma20, ma50, ma100, ma200, RSI, MACDLine, SignalLine, MACDHistogram, volumes, } = this._getChartData(); // Update labels (dates) this.chart.data.labels = dates; // Update each dataset this.chart.data.datasets[0].data = RSI; // RSI this.chart.data.datasets[1].data = MACDHistogram; // MACDHistogram this.chart.data.datasets[2].data = SignalLine; // SignalLine this.chart.data.datasets[3].data = MACDLine; // MACDLine this.chart.data.datasets[4].data = closePrices; // Close Price this.chart.data.datasets[5].data = ma20; // MA20 this.chart.data.datasets[6].data = ma50; // MA50 this.chart.data.datasets[7].data = ma100; // MA50 this.chart.data.datasets[8].data = ma200; // MA200 this.chart.data.datasets[9].data = volumes; // Volume this.chart.data.datasets[10].data = ma10; // Volume this.chart.data.datasets[11].data = ma5; // Volume // Trigger chart update this.chart.update(); } } // This will handle creating the Chart.js chart _createChart() { const { dates, closePrices, ma5, ma10, ma20, ma50, ma100, ma200, RSI, MACDLine, SignalLine, MACDHistogram, volumes, } = this._getChartData(); const canvas = this.shadowRoot?.getElementById( "stockChart" ) as HTMLCanvasElement; if (!canvas) { return; } const ctx = canvas.getContext("2d"); if (!ctx) { return; } if (this.chart) { this.chart.destroy(); // Destroy existing chart if necessary } // Register the custom plugin Chart.register(this.customLinePlugin); Chart.register(zoomPlugin); this.chart = new Chart(ctx, { type: "line", data: { labels: dates, datasets: [ { label: "RSI", data: RSI, borderColor: "rgba(15, 92, 92, 1)", backgroundColor: "rgba(15, 92, 92, 0.2)", fill: false, borderWidth: 2, yAxisID: "yRSI", pointRadius: 1, pointHoverRadius: 3, hidden: true, }, { label: "MACDHistogram", data: MACDHistogram, borderColor: "rgba(175, 92, 92, 1)", backgroundColor: "rgba(175, 92, 92, 0.2)", fill: false, borderWidth: 2, yAxisID: "yMACD", hidden: true, pointRadius: 1, pointHoverRadius: 3, }, { label: "SignalLine", data: SignalLine, borderColor: "rgba(75, 92, 192, 1)", backgroundColor: "rgba(75, 92, 192, 0.2)", fill: false, borderWidth: 1, yAxisID: "yMACD", hidden: true, pointRadius: 1, pointHoverRadius: 3, }, { label: "MACDLine", data: MACDLine, borderColor: "rgba(175, 192, 192, 1)", backgroundColor: "rgba(175, 192, 192, 0.2)", fill: false, borderWidth: 1, yAxisID: "yMACD", hidden: true, pointRadius: 1, pointHoverRadius: 3, }, { label: "Close Price", data: closePrices, borderColor: "rgba(75, 192, 192, 1)", backgroundColor: "rgba(75, 192, 192, 0.2)", fill: true, borderWidth: 1, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, { label: "MA20", data: ma20, borderColor: "rgba(255, 99, 132, 1)", backgroundColor: "rgba(255, 99, 132, 0.2)", fill: false, borderWidth: 2, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, { label: "MA50", data: ma50, borderColor: "rgba(255, 206, 86, 1)", backgroundColor: "rgba(255, 206, 86, 0.2)", fill: false, borderWidth: 2, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, { label: "MA100", data: ma100, borderColor: "rgba(153, 102, 25, 1)", backgroundColor: "rgba(153, 102, 25, 0.2)", fill: false, borderWidth: 2, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, { label: "MA200", data: ma200, borderColor: "rgba(153, 102, 255, 1)", backgroundColor: "rgba(153, 102, 255, 0.2)", fill: false, borderWidth: 2, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, { label: "Volume", data: volumes, // Volume data yAxisID: "yVolume", // Assign to the volume axis fill: true, backgroundColor: "rgba(252, 3, 57, 0.5)", borderWidth: 1, pointRadius: 1, pointHoverRadius: 3, }, { label: "MA10", data: ma10, borderColor: "rgba(153, 12, 25, 1)", backgroundColor: "rgba(153, 12, 25, 0.2)", fill: false, borderWidth: 1, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, { label: "MA5", data: ma5, borderColor: "rgba(13, 12, 25, 1)", backgroundColor: "rgba(13, 12, 25, 0.2)", fill: false, borderWidth: 1, yAxisID: "yPrice", pointRadius: 1, pointHoverRadius: 3, }, ], }, options: { responsive: true, scales: { x: { ticks: { color: (ctx) => { const label = ctx.tick.label; if (typeof label === "string") { const labelDate = new Date(label).toISOString().split("T")[0]; const todayStr = new Date().toISOString().split("T")[0]; return labelDate === todayStr ? "Salmon" : "black"; } return "black"; // default fallback }, }, }, yPrice: { type: "linear", position: "left", beginAtZero: false, title: { display: true, text: "Price" }, }, yRSI: { type: "linear", position: "right", beginAtZero: true, title: { display: true, text: "RSI" }, grid: { drawOnChartArea: false }, }, yMACD: { type: "linear", position: "right", beginAtZero: true, title: { display: true, text: "MACD" }, grid: { drawOnChartArea: false }, }, yVolume: { type: "linear", position: "left", beginAtZero: true, title: { display: true, text: "Volume" }, grid: { drawOnChartArea: false }, // To prevent grid overlap }, }, interaction: { mode: "index", intersect: false }, plugins: { tooltip: { mode: "index", axis: "x" }, zoom: { pan: { enabled: true, mode: "x", modifierKey: undefined, // Optional: only pan while holding Ctrl }, zoom: { wheel: { enabled: true, }, pinch: { enabled: true, }, mode: "x", }, limits: { x: { minRange: 5 }, // prevent zooming too far }, }, }, }, }); } render() { return html` <button @click="${this._resetZoom}">Reset Zoom</button> <canvas id="stockChart"></canvas> `; } } declare global { interface HTMLElementTagNameMap { "stock-chart-display": ChartElement; } }