diff --git a/bar/eww/.gitignore b/bar/eww/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/bar/eww/.gitignore @@ -0,0 +1 @@ +.env diff --git a/bar/eww/eww.scss b/bar/eww/eww.scss new file mode 100644 index 0000000..344ffb5 --- /dev/null +++ b/bar/eww/eww.scss @@ -0,0 +1,30 @@ +* { + all: unset; + font-family: "FiraCode Nerd Font"; +} + +.trades-box { + margin: 12px; + padding: 8px; + background-color: #121212; + border-radius: 8px; +} + +.profit { + background-color: #121212; + border: solid 4px; + border-color: #12bb7b; + border-radius: 5px; + + padding: 3px; + margin-left: 8px; + + &.loss { + border-color: red; + } + + .arrow { + margin-left: 6px; + font-size: 18px; + } +} diff --git a/bar/eww/eww.yuck b/bar/eww/eww.yuck new file mode 100644 index 0000000..b6e72b8 --- /dev/null +++ b/bar/eww/eww.yuck @@ -0,0 +1,59 @@ +(defpoll bots + :initial `[]` + :interval "3s" + `curl http://localhost:42667/list` +) + +(defpoll absolute_profit + :interval "1s" + :initial 0 + `echo $((RANDOM % 11))` +) + +(defwidget profit-box [percent price] + (box :class {price >= 0 ? "profit" : "profit loss"} :space-evenly false + (label :text {price >= 0 ? "▲" : "▼"} :class "arrow" :halign "start") + (label :text "${round(percent, 2)}% (${round(price, 2)})" :class "price" :justify "center" :hexpand true) + ) +) + +(defwidget trades-box [] + (box :orientation "v" :class "trades-box" + (box :orientation "h" :style "margin: 2px; font-weight: bold" + (label :text "Freqtrade" :style "font-family: Arial; font-size: 24px") + (label :text "Open profit") + (label :text "Closed profit") + ) + (for bot in bots + (box :orientation "h" :style "margin: 2px" + (label :text "${bot.name}" :halign "start" :style "font-weight: bold; margin-right: 8px") + (profit-box :percent {bot.open_profit_pct} :price {bot.open_profit}) + (profit-box :percent {bot.closed_profit_pct} :price {bot.closed_profit}) + ) + ) + ) +) + +(defwidget profits-graph [] + (box :class "trades-box" + (graph + :value {arraylength(bots) > 1 ? -bots[2].open_profit_pct * 2.0 : 0} + :time-range "20m" + :thickness 2 + :line-style "round" + :dynamic true + ) + ) +) + +(defwindow freqtrade + :monitor 0 + :stacking "bg" + :geometry (geometry :x "0%" + :anchor "top right" + ) + (box :orientation "v" + (trades-box) + (profits-graph) + ) +) diff --git a/bar/eww/scripts/freqtraded.ts b/bar/eww/scripts/freqtraded.ts new file mode 100755 index 0000000..13d0e17 --- /dev/null +++ b/bar/eww/scripts/freqtraded.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env bun + +import http from "http"; + +const API_ADDRESS = "127.0.0.1"; +const API_PORT = 42667; + +type LoginResponse = { + access_token: string; + refresh_token: string; +}; + +type BotData = { + url: string; + auth: LoginResponse; + summary: BotSummary; +}; + +type BotSummary = { + name: string; + dry_run: boolean; + closed_profit: number; + closed_profit_pct: number; + open_profit: number; + open_profit_pct: number; +}; + +let bots: BotData[] = []; + +const URLS = JSON.parse(process.env.FREQTRADE_URLS); + +async function login( + url: string, + user: string, + pass: string, +): Promise { + const response = await fetch(`${url}/api/v1/token/login`, { + method: "POST", + headers: { + Authorization: "Basic " + btoa(`${user}:${pass}`), + "Content-Type": "application/json", + }, + }); + + if (response.status !== 200) { + throw Error("Failed to login"); + } + + return (await response.json()) as LoginResponse; +} + +async function refresh_token(bot: BotData) { + const response = await fetch(`${bot.url}/api/v1/token/refresh`, { + method: "POST", + headers: { + Authorization: "Bearer " + bot.auth.refresh_token, + "Content-Type": "application/json", + }, + }); + + if (response.status !== 200) { + throw Error("Failed to login"); + } + + bot.auth.access_token = (await response.json()).access_token; +} + +async function get_summary( + url: string, + auth: LoginResponse, +): Promise { + // Fetch config + const config_response = await fetch(`${url}/api/v1/show_config`, { + headers: { + Authorization: "Bearer " + auth.access_token, + }, + }); + + if (config_response.status !== 200) { + throw Error("Failed to get bot config"); + } + + const config = await config_response.json(); + + // Fetch trades + const trades_response = await fetch(`${url}/api/v1/status`, { + headers: { + Authorization: "Bearer " + auth.access_token, + }, + }); + + if (trades_response.status !== 200) { + throw Error("Failed to get bot config"); + } + + const trades = await trades_response.json(); + + // Fetch balance + const profit_response = await fetch(`${url}/api/v1/profit`, { + headers: { + Authorization: "Bearer " + auth.access_token, + }, + }); + + if (profit_response.status !== 200) { + throw Error("Failed to get bot config"); + } + + const profit = await profit_response.json(); + + return { + name: config.bot_name as string, + dry_run: config.dry_run as boolean, + open_profit: trades.length + ? trades.map((v) => v.profit_abs).reduce((a, b) => a + b) + : 0, + open_profit_pct: trades.length + ? trades.map((v) => v.profit_pct).reduce((a, b) => a + b) / trades.length + : 0, + closed_profit: profit.profit_closed_coin, + closed_profit_pct: profit.profit_closed_percent, + }; +} + +async function requestListener( + req: http.IncomingMessage, + res: http.ServerResponse, +) { + switch (req.url) { + case "/": + res.writeHead(200); + res.end("Welcome to the freqtrade API"); + break; + case "/list": + // Update bots + await Promise.all( + bots.map(async (bot) => { + bot.summary = await get_summary(bot.url, bot.auth); + }), + ); + + res.writeHead(200); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(bots.map((b) => b.summary))); + break; + default: + let bot_name = req.url?.split("/")[1]; + + let bot = bots.find((v) => v.summary.name == bot_name); + + if (bot) { + let response = await fetch( + `${bot.url}${req.url?.replace(`/${bot_name}`, "")}`, + { + headers: { + Authorization: "Bearer " + bot.auth.access_token, + }, + }, + ); + + res.writeHead(response.status); + res.end(await response.text()); + } else { + res.writeHead(404); + res.end(JSON.stringify({ error: "Resource not found" })); + } + } +} + +async function daemon() { + for (let url of URLS) { + console.info("[INFO] Logging in for " + url); + let auth = await login( + url, + process.env.FREQTRADE_USER!, + process.env.FREQTRADE_PASS!, + ); + + let bot: BotData = { + url, + auth, + summary: await get_summary(url, auth), + }; + + setInterval( + async () => { + console.info("[INFO] Refreshing token for " + url); + await refresh_token(bot); + }, + 10 * (1 - (Math.random() - 0.5) / 4) * 60 * 1000, + ); + + bots.push(bot); + } + + console.info("[INFO] Starting Web Server"); + + const server = http.createServer(requestListener); + server.listen(API_PORT, API_ADDRESS, () => { + console.info( + `[INFO] Web server running on http://${API_ADDRESS}:${API_PORT}`, + ); + }); +} + +await daemon(); diff --git a/sync b/sync index 83316b1..1318090 100755 --- a/sync +++ b/sync @@ -24,6 +24,7 @@ synced_files = [ ("term/alacritty/", "~/.config/alacritty/"), ("bar/waybar/", "~/.config/waybar/"), ("bar/i3status-rust/", "~/.config/i3status-rust/"), + ("bar/eww/", "~/.config/eww/"), ("home/xinitrc", "~/.xinitrc"), ("misc/picom/", "~/.config/picom/"), ("misc/runst/", "~/.config/runst/"),