Crypto Ticker

@rebindv0.1.0

Keeps live coin prices on screen over every app, with a hotkey menu for price alerts.

Files

Version 0.1.0 (current)

main.luau

-- Crypto Ticker: a strip of live CoinGecko prices over every app, a hotkey to
-- show or hide it, and a menu to refresh, pick a coin and set price alerts
-- that pop up as a notice when the price crosses them.

local CURRENCIES = { "USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF" }
local POSITIONS = {
  "top",
  "top-left",
  "top-right",
  "bottom",
  "bottom-left",
  "bottom-right",
}
local THEMES =
  { "rebind", "rebind-light", "catppuccin", "tokyo-night", "contrast" }

local cfg = rb.ui.schema({
  menu_key = rb.ui.keybind("F8", { label = "Open the menu" }),
  toggle_key = rb.ui.keybind("F7", { label = "Show or hide the ticker" }),
  coins = rb.ui.text("bitcoin, ethereum, solana", {
    label = "Coins",
    tooltip = "CoinGecko ids, separated by commas: the end of a coin's page address on coingecko.com",
  }),
  currency = rb.ui.select("USD", CURRENCIES, { label = "Currency" }),
  refresh = rb.ui.slider(60, {
    label = "Refresh every",
    min = 30,
    max = 600,
    step = 30,
    suffix = " s",
  }),
  position = rb.ui.select("top", POSITIONS, { label = "Position" }),
  theme = rb.ui.select("rebind", THEMES, { label = "Theme" }),
  beep = rb.ui.toggle(true, { label = "Beep on alerts" }),
})

local API =
  "https://api.coingecko.com/api/v3/coins/markets?vs_currency=%s&ids=%s"
-- CoinGecko refuses a request without a User-Agent with 403
local REQUEST = {
  headers = { ["User-Agent"] = "Rebind Crypto Ticker" },
  timeout = 10000,
}
-- hidden, so a packed copy of the package never carries someone's alerts
local ALERTS_FILE = ".alerts.json"
local SYMBOLS = { USD = "$", EUR = "€", GBP = "£", JPY = "¥" }

-- { id, symbol, name, price, change }, in the order of cfg.coins
local coins = {}
-- shown after the prices while something is wrong
local note = "loading"
local updated = nil
local shown = true
local wake = false
local strip, strip_key = nil, nil
-- { id, symbol, currency, above, price }
local alerts = {}

local function money(price)
  local sign = SYMBOLS[cfg.currency] or (cfg.currency .. " ")
  if price >= 1000 then
    local reversed = string.reverse(tostring(math.floor(price + 0.5)))
    local grouped = string.reverse((string.gsub(reversed, "(%d%d%d)", "%1,")))
    return sign .. (string.gsub(grouped, "^,", ""))
  elseif price >= 1 then
    return sign .. string.format("%.2f", price)
  elseif price <= 0 then
    return sign .. "0"
  end
  -- four significant digits (0.5123, 0.00000488), trailing zeros dropped
  -- past the second decimal
  local decimals = math.min(3 - math.floor(math.log10(price)), 12)
  local text = string.format("%." .. decimals .. "f", price)
  return sign .. (string.gsub(text, "(%.%d%d%d-)0+$", "%1"))
end

local function percent(change)
  if type(change) ~= "number" then
    return { "", "faint" }
  end
  local arrow = if change >= 0 then "▲" else "▼"
  local role = if change >= 0 then "positive" else "danger"
  return { string.format("%s%.1f%%", arrow, math.abs(change)), role }
end

-- the CoinGecko ids in cfg.coins, and the entries that cannot be one
local function ids()
  local list, bad = {}, {}
  for _, part in string.split(cfg.coins, ",") do
    local id = string.lower((string.gsub(part, "%s", "")))
    if string.match(id, "^[%w%-]+$") then
      table.insert(list, id)
    elseif id ~= "" then
      table.insert(bad, id)
    end
  end
  return list, bad
end

-- strip ------------------------------------------------------------------

local function draw()
  if not shown then
    if strip then
      strip.remove()
      strip = nil
    end
    return
  end
  local spans = {}
  for i, c in coins do
    if i > 1 then
      table.insert(spans, { "   " })
    end
    table.insert(spans, { string.upper(c.symbol) .. " ", "dim" })
    table.insert(spans, { money(c.price) .. " ", "text", true })
    table.insert(spans, percent(c.change))
  end
  if note then
    local gap = if #coins > 0 then "   " else ""
    table.insert(spans, { gap .. note, "faint" })
  end
  -- position and theme are fixed when a strip is made: a change needs a new one
  local key = cfg.position .. "/" .. cfg.theme
  if strip and strip_key ~= key then
    strip.remove()
    strip = nil
  end
  -- a strip too wide for an overlay keeps its last state instead of
  -- stopping the price loop
  local ok, err = pcall(function()
    if strip then
      strip.set(spans)
    else
      strip =
        rb.overlay.status(spans, { anchor = cfg.position, theme = cfg.theme })
      strip_key = key
    end
  end)
  if not ok then
    rb.log.warn("could not draw the ticker: " .. tostring(err))
  end
end

-- notices go top-right, below the strip when it sits there too
local function notice(text, options)
  options.theme = cfg.theme
  if cfg.position == "top-right" then
    options.offset_y = 56
  end
  local ok, err = pcall(rb.overlay.toast, text, options)
  if not ok then
    rb.log.warn("could not show a notice: " .. tostring(err))
  end
end

-- alerts -----------------------------------------------------------------

local function save_alerts()
  local ok, err = pcall(rb.file.write_json, ALERTS_FILE, alerts)
  if not ok then
    rb.log.warn("could not save alerts: " .. tostring(err))
  end
end

local function load_alerts()
  if not rb.file.exists(ALERTS_FILE) then
    return
  end
  local ok, saved = pcall(rb.file.read_json, ALERTS_FILE)
  if not ok or type(saved) ~= "table" then
    return
  end
  -- an edited or damaged file keeps only the entries that are whole
  for _, a in saved do
    if
      type(a) == "table"
      and type(a.id) == "string"
      and type(a.symbol) == "string"
      and type(a.currency) == "string"
      and type(a.above) == "boolean"
      and type(a.price) == "number"
    then
      table.insert(alerts, a)
    end
  end
end

local function remove_alert(a)
  local i = table.find(alerts, a)
  if i then
    table.remove(alerts, i)
    save_alerts()
  end
end

-- alerts for coin `id` in the current currency
local function alerts_for(id)
  local list = {}
  for _, a in alerts do
    if a.id == id and a.currency == cfg.currency then
      table.insert(list, a)
    end
  end
  return list
end

local function crossed(a, price)
  if a.above then
    return price >= a.price
  end
  return price <= a.price
end

local function check_alerts()
  local by_id = {}
  for _, c in coins do
    by_id[c.id] = c
  end
  local fired = {}
  for i = #alerts, 1, -1 do
    local a = alerts[i]
    local c = by_id[a.id]
    if c and a.currency == cfg.currency and crossed(a, c.price) then
      table.remove(alerts, i)
      table.insert(fired, { alert = a, coin = c })
    end
  end
  if #fired == 0 then
    return
  end
  -- saved first, so an alert never fires twice
  save_alerts()
  for _, f in fired do
    local role = if f.alert.above then "positive" else "danger"
    local text = {
      { string.upper(f.alert.symbol) .. " ", "text", true },
      { money(f.coin.price), role, true },
    }
    local verb = if f.alert.above then "rose above " else "fell below "
    notice(text, {
      title = "Price alert",
      meta = verb .. money(f.alert.price),
      duration = 10000,
    })
  end
  if cfg.beep then
    rb.audio.beep()
  end
end

-- prices -----------------------------------------------------------------

-- the coins CoinGecko knew, in the order asked for, and the ids it did not
local function read_rows(data, wanted)
  local by_id = {}
  for _, row in data do
    if
      type(row) == "table"
      and type(row.id) == "string"
      and type(row.current_price) == "number"
    then
      by_id[row.id] = row
    end
  end
  local found, missing = {}, {}
  for _, id in wanted do
    local row = by_id[id]
    if row then
      -- a JSON null is not nil in Luau, so test the type
      table.insert(found, {
        id = id,
        symbol = if type(row.symbol) == "string" then row.symbol else id,
        name = if type(row.name) == "string" then row.name else id,
        price = row.current_price,
        change = row.price_change_percentage_24h,
      })
    else
      table.insert(missing, id)
    end
  end
  return found, missing
end

local function refresh()
  local wanted, bad = ids()
  if #wanted == 0 then
    coins = {}
    note = if #bad > 0
      then "unknown: " .. table.concat(bad, ", ")
      else "add coins in the settings"
    draw()
    return
  end
  local currency = string.lower(cfg.currency)
  local url = string.format(API, currency, table.concat(wanted, ","))
  local ok, res = pcall(rb.net.get, url, REQUEST)
  if not ok then
    note = "offline"
    rb.log.warn("CoinGecko did not answer: " .. tostring(res))
  elseif res.status == 429 then
    note = "CoinGecko is limiting requests"
  elseif res.status ~= 200 then
    note = "CoinGecko answered " .. res.status
  else
    local parsed, data = pcall(rb.json.parse, res.body)
    if not parsed or type(data) ~= "table" then
      note = "CoinGecko sent something unreadable"
    else
      local found, missing = read_rows(data, wanted)
      for _, id in bad do
        table.insert(missing, id)
      end
      coins = found
      updated = os.date("%H:%M")
      note = nil
      if #missing > 0 then
        note = "unknown: " .. table.concat(missing, ", ")
      end
      check_alerts()
    end
  end
  draw()
end

-- menu -------------------------------------------------------------------

local open_main

local function open_coin(c)
  local items = {}
  for _, step in { 1, 5, -1, -5 } do
    local target = c.price * (1 + step / 100)
    local verb = if step > 0 then "Alert above " else "Alert below "
    table.insert(items, {
      label = verb .. money(target),
      hint = string.format("%+d%%", step),
      action = function()
        table.insert(alerts, {
          id = c.id,
          symbol = c.symbol,
          currency = cfg.currency,
          above = step > 0,
          price = target,
        })
        save_alerts()
        local text = "Alert set for "
          .. string.upper(c.symbol)
          .. " at "
          .. money(target)
        notice(text, {})
      end,
    })
  end
  for _, a in alerts_for(c.id) do
    local side = if a.above then "above " else "below "
    table.insert(items, {
      label = "Remove " .. side .. money(a.price),
      action = function()
        remove_alert(a)
      end,
    })
  end
  table.insert(items, {
    label = "Back",
    action = function()
      open_main()
    end,
  })
  local menu, why = rb.overlay.menu(items, {
    title = string.upper(c.symbol),
    subtitle = c.name .. "  " .. money(c.price),
    theme = cfg.theme,
  })
  if not menu then
    rb.log.warn("no menu: " .. why)
  end
end

open_main = function()
  local items = {}
  for _, c in coins do
    local count = #alerts_for(c.id)
    local hint = nil
    if count > 0 then
      hint = count .. (if count == 1 then " alert" else " alerts")
    end
    table.insert(items, {
      label = string.upper(c.symbol) .. "  " .. money(c.price),
      hint = hint,
      action = function()
        open_coin(c)
      end,
    })
  end
  table.insert(items, {
    label = if shown then "Hide the ticker" else "Show the ticker",
    action = function()
      shown = not shown
      draw()
    end,
  })
  table.insert(items, {
    label = "Refresh now",
    action = function()
      wake = true
    end,
  })
  if #alerts > 0 then
    table.insert(items, {
      label = "Clear every alert",
      hint = tostring(#alerts),
      action = function()
        alerts = {}
        save_alerts()
      end,
    })
  end
  local menu, why = rb.overlay.menu(items, {
    title = "Crypto Ticker",
    subtitle = note or (if updated then "updated " .. updated else ""),
    theme = cfg.theme,
  })
  if not menu then
    rb.log.warn("no menu: " .. why)
  end
end

rb.bind(cfg.menu_key, function()
  open_main()
end)

rb.bind(cfg.toggle_key, function()
  shown = not shown
  draw()
end)

rb.on("start", function()
  load_alerts()
  draw()
  rb.run(function()
    while true do
      refresh()
      local waited = 0
      while waited < cfg.refresh * 1000 and not wake do
        rb.sleep(250)
        waited += 250
      end
      wake = false
    end
  end)
end)