Last active
March 7, 2025 09:37
-
-
Save yangirov/f2ba5f3d2e727f17aaf1fb7590932c56 to your computer and use it in GitHub Desktop.
Stocks Image updater
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const fs = require("fs"); | |
const { exec } = require("child_process"); | |
const { createCanvas } = require("canvas"); | |
const { TinkoffInvestApi } = require("tinkoff-invest-api"); | |
const WIDTH = 1280; | |
const HEIGHT = 720; | |
const OUTPUT_FILE = "./background.png"; | |
const IS_DEV = process.env.NODE_ENV === "development"; | |
// Время обновления в секундах | |
const UPDATE_INTERVAL = 60_000; | |
// Идентификатор акции | |
const FIGI = process.env.FIGI; | |
// Токен в Тинькоф Инвестициях (желательно выпустить в песочнице) | |
// Инструкция: https://tinkoff.github.io/investAPI/token/ | |
const TOKEN = process.env.TOKEN; | |
const api = new TinkoffInvestApi({ token: TOKEN }); | |
// Интервал сделок | |
const CANDLE_REQUEST = { | |
interval: 7, | |
limit: 250, | |
...api.helpers.fromTo("-12h"), | |
}; | |
async function getInstrumentByFigi(figi) { | |
const { instrument } = await api.instruments.getInstrumentBy({ | |
id: figi, | |
idType: 1, | |
}); | |
if (!instrument) { | |
throw new Error(`Инструмент с FIGI ${figi} не найден`); | |
} | |
return instrument; | |
} | |
async function getCandles() { | |
const { candles } = await api.marketdata.getCandles({ | |
figi: FIGI, | |
...CANDLE_REQUEST, | |
}); | |
if (!candles?.length) { | |
throw new Error("No candles"); | |
} | |
return candles.map((c) => ({ | |
time: new Date(c.time), | |
close: api.helpers.toNumber(c.close), | |
})); | |
} | |
async function createChart() { | |
const instrument = await getInstrumentByFigi(FIGI); | |
const data = await getCandles(); | |
const closes = data.map((d) => d.close); | |
const times = data.map((d) => d.time.getTime()); | |
const minPrice = Math.min(...closes); | |
const maxPrice = Math.max(...closes); | |
const avgPrice = closes.reduce((sum, v) => sum + v, 0) / closes.length; | |
const firstPrice = closes[0]; | |
const lastPrice = closes[closes.length - 1]; | |
const diff = lastPrice - firstPrice; | |
const percent = (diff / firstPrice) * 100; | |
const canvas = createCanvas(WIDTH, HEIGHT); | |
const ctx = canvas.getContext("2d"); | |
ctx.fillStyle = "#0d0d0d"; | |
ctx.fillRect(0, 0, WIDTH, HEIGHT); | |
const chartX = 40; | |
const chartY = 80; | |
const chartWidth = WIDTH - chartX - 80; | |
const chartHeight = HEIGHT - chartY - 60; | |
const xMin = Math.min(...times); | |
const xMax = Math.max(...times); | |
function getX(time) { | |
return chartX + ((time - xMin) / (xMax - xMin)) * chartWidth; | |
} | |
function getY(price) { | |
return chartY + ((maxPrice - price) / (maxPrice - minPrice)) * chartHeight; | |
} | |
// Линия графика | |
ctx.beginPath(); | |
data.forEach((point, i) => { | |
const x = getX(point.time.getTime()); | |
const y = getY(point.close); | |
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); | |
}); | |
ctx.strokeStyle = "#00ff00"; | |
ctx.lineWidth = 2; | |
ctx.stroke(); | |
// Закрашиваем область под линией (градиент) | |
ctx.lineTo(getX(times[times.length - 1]), chartY + chartHeight); | |
ctx.lineTo(getX(times[0]), chartY + chartHeight); | |
ctx.closePath(); | |
const gradient = ctx.createLinearGradient(0, chartY, 0, chartY + chartHeight); | |
gradient.addColorStop(0, "rgba(0,255,0,0.3)"); | |
gradient.addColorStop(1, "rgba(0,255,0,0)"); | |
ctx.fillStyle = gradient; | |
ctx.fill(); | |
// Горизонтальные линии для min, avg, max | |
ctx.strokeStyle = "#444444"; | |
ctx.lineWidth = 1; | |
ctx.setLineDash([5, 5]); | |
[minPrice, avgPrice, maxPrice].forEach((price) => { | |
ctx.beginPath(); | |
ctx.moveTo(chartX, getY(price)); | |
ctx.lineTo(chartX + chartWidth, getY(price)); | |
ctx.stroke(); | |
}); | |
ctx.setLineDash([]); | |
// Подписи: мин/сред/макс справа | |
ctx.fillStyle = "#cccccc"; | |
ctx.font = "20px sans-serif"; | |
ctx.textAlign = "left"; | |
ctx.fillText(minPrice.toFixed(2), chartX + chartWidth + 10, getY(minPrice)); | |
ctx.fillText(avgPrice.toFixed(2), chartX + chartWidth + 10, getY(avgPrice)); | |
ctx.fillText(maxPrice.toFixed(2), chartX + chartWidth + 10, getY(maxPrice)); | |
// Подписи времени снизу (левый и правый край) | |
ctx.textAlign = "center"; | |
ctx.fillText(formatTime(new Date(xMin)), chartX, chartY + chartHeight + 30); | |
ctx.fillText( | |
formatTime(new Date(xMax)), | |
chartX + chartWidth, | |
chartY + chartHeight + 30 | |
); | |
// Стрелка и процент сверху | |
const arrow = diff >= 0 ? "▲" : "▼"; | |
const arrowColor = diff >= 0 ? "#00ff00" : "#ff4444"; | |
ctx.fillStyle = arrowColor; | |
ctx.textAlign = "left"; | |
ctx.font = "40px sans-serif"; | |
const ticker = instrument.ticker; | |
const label = `${ticker}: ${lastPrice} ₽, ${arrow} ${Math.abs(diff).toFixed( | |
2 | |
)} ₽ (${percent.toFixed(2)}%)`; | |
ctx.fillText(label, chartX, chartY - 25); | |
// Время в правом верхнем углу | |
ctx.textAlign = "right"; | |
ctx.fillStyle = "#00ff00"; | |
ctx.font = "40px sans-serif"; | |
const now = new Date(); | |
ctx.fillText(formatTime(now), WIDTH - 90, 55); | |
fs.writeFileSync(OUTPUT_FILE, canvas.toBuffer("image/png")); | |
} | |
function formatTime(date) { | |
const h = date.getHours().toString().padStart(2, "0"); | |
const m = date.getMinutes().toString().padStart(2, "0"); | |
return `${h}:${m}`; | |
} | |
function sendOBSHotKey(keyCode) { | |
const appleScriptCmd = `osascript -e ' | |
tell application "OBS" to activate | |
tell application "System Events" | |
tell process "OBS" | |
set frontmost to true | |
key down 63 -- fn | |
key code ${keyCode} | |
set visible to false | |
end tell | |
end tell | |
tell application "Google Chrome" to activate'`; | |
exec(appleScriptCmd, (error, stdout, stderr) => { | |
if (error) { | |
console.error(`Ошибка отправки горячей клавиши ${keyCode}: ${error}`); | |
return; | |
} | |
console.log(`Горячая клавиша ${keyCode} отправлена`); | |
}); | |
} | |
function startOBS() { | |
exec('open -a "OBS"', (error, stdout, stderr) => { | |
if (error) { | |
console.error(`Ошибка запуска OBS: ${error}`); | |
return; | |
} | |
console.log("OBS запущен, ожидаем пару секунд..."); | |
setTimeout(() => { | |
sendOBSHotKey(111); // F12 – старт камеры | |
}, 5000); | |
}); | |
} | |
async function stopOBS() { | |
sendOBSHotKey(109); // F10 – стоп камеры | |
// Дадим OBS пару секунд на обработку команды, затем закроем приложение | |
// await new Promise((resolve) => | |
// setTimeout(() => { | |
// const quitCmd = `osascript -e 'tell application "OBS" to if it is running then quit'`; | |
// exec(quitCmd, (error, stdout, stderr) => { | |
// if (error) { | |
// console.error(`Ошибка закрытия OBS: ${error}`); | |
// resolve(); | |
// return; | |
// } | |
// console.log("OBS закрыт"); | |
// resolve(); | |
// }); | |
// }, 1000) | |
// ); | |
} | |
async function updateBackground() { | |
try { | |
await createChart(); | |
console.log("Chart saved to", OUTPUT_FILE); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
setInterval(updateBackground, UPDATE_INTERVAL); | |
updateBackground(); | |
if (!IS_DEV) { | |
startOBS(); | |
} | |
async function gracefulShutdown() { | |
if (!IS_DEV) { | |
console.log("Завершаем работу, останавливаем виртуальную камеру..."); | |
await stopOBS(); | |
process.exit(); | |
} | |
} | |
process.on("SIGINT", gracefulShutdown); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment