stock-chart-display
Version:
Lit Web Component to display stock charts
491 lines (459 loc) • 14.9 kB
text/typescript
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;
}
class ChartElement extends LitElement {
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 ="${this._resetZoom}">Reset Zoom</button>
<canvas id="stockChart"></canvas>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"stock-chart-display": ChartElement;
}
}