--!name Stocks --!icon chart -- -- A watchlist the owner edits on the device, and one symbol at a time with a -- price chart, two moving averages and RSI. Nothing is fetched until asked for. -- THE INTERFACE IS A HIERARCHY. LEFT goes up a level, RIGHT goes in, UP and DOWN -- move within it, OK acts. This app needs its own way up because BACK leaves it -- entirely and cannot be bound. -- -- THE DATA -- https://query1.finance.yahoo.com/v8/finance/chart/AAPL -- ?range=3mo&interval=1d -- -- No API key, no cookie, no session crumb, HTTPS. The v7 spark endpoint this -- app shipped on now answers 429 to everything (verified 2026-08-10); v8 chart -- still answers, carries the same meta names, and its extra open/high/low/ -- volume arrays cost only staging — the extractor pulls close[] by name and -- steps past the rest. ~7.5 KB against a 24 KB staging buffer. -- -- THE DOCUMENT NEVER BECOMES A LUA STRING. Cutting the series out of the body -- with a pattern once put this app at 92% of its budget on a real BTC-USD -- reply; the engine now hands the elements of a named array to a sink one at a -- time, out of the staging buffer — see docs/LUA.md. -- -- The endpoint is undocumented, so a failure on every symbol at once means Yahoo -- changed something. Quotes run fifteen minutes late and there is no clock here, -- so a price reads CACHED until it is refreshed in this session. -- -- TWO CEILINGS: 49,152 bytes of memory and 24,576 bytes of source. A comment is -- free against the first and paid for against the second, and this file is -- within a hundred bytes of the second, so a new sentence has to displace one. -- --- limits local MAX_SYMS = 8 -- one round trip each, and 1 KB of saved state local COLS = 30 -- the chart is a board, and a board is 32 cells wide -- Board depth is affordable because the reply is not held while it is built. local CHART_ROWS = 30 local SLOTS = 8 -- characters in a spelled symbol -- MOVING AVERAGE PERIODS. Two trading weeks and one month, both defined across -- all thirty drawn bars. Fifty and two hundred day averages cannot come out of -- three months of data. RSI is fourteen and is computed Wilder's way with the -- smoothed averages rather than as a plain mean of the last fourteen changes, -- because the two disagree and the smoothed form is what every chart draws. local MA_FAST, MA_SLOW = 10, 20 local period = 1 local HOST = "https://query1.finance.yahoo.com/v8/finance/chart/" local function period_name() return period == 1 and "Day" or (period == 2 and "Month" or "Quarter") end local function period_tail() return period == 1 and "?range=1d&interval=5m" or (period == 2 and "?range=1mo&interval=1d" or "?range=3mo&interval=1d") end local WHEEL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-^ " -- --- state -- The list is the owner's; this is only what a card with no saved blob starts -- with, and it holds no equity on purpose. local list = {"^GSPC", "^IXIC", "^DJI", "BTC-USD"} -- ONE QUOTE IS ONE STRING, parallel to `list` and in the same order. Its first -- field is the watchlist row exactly as it is drawn, and the four after it are -- the price, the change, the day's range and the volume for the symbol page. -- Everything is formatted at the moment it arrives, so a frame of the watchlist -- allocates one substring a row rather than a format, a match and an arrow. -- `L` is parallel again and says whether that row was fetched in THIS session, -- which is what keeps a number off the card from being drawn as though it were -- live. local Q, L = {}, {} -- THE SERIES IS ONE INTERLEAVED RING of the last COLS columns, four numbers to a -- column (close, fast average, slow average, RSI) at -- ((Shead + c - 1) % COLS) * 4 + field for the c'th column left to right. A -- column with no average holds false rather than nil, because a nil punches a -- hole in the array and pushes the rest into the hash part. Only the open symbol -- has a series: eight of them was six kilobytes to draw thirty numbers. local S, Shead, Scols, Ssym = {}, 0, 0, nil local function drop() S, Ssym, Scols, Shead = {}, nil, 0, 0 end -- THE ACCUMULATORS LIVE HERE RATHER THAN INSIDE THE FETCH so that the sink is -- one function made once when this file loads. A sink built per request is a -- closure and a box for every upvalue it captures, allocated at the moment -- the reply is arriving. local Sn, Sfast, Sslow, Sprev, Sback = 0, 0, 0, 0, 0 local view = "home" -- "home" | "sym" | "edit" | "add" local sel = 1 local esel, armed = 1, nil local msg = "" local buf, bpos = {}, 1 -- the spelling wheel, one WHEEL index per slot -- --- numbers -- Decimal places follow the PRICE's magnitude: two would print a seven cent -- coin's every move as +0.00. local function places(p) p = math.abs(p or 0) return p >= 1 and 2 or (p >= 0.01 and 4 or 6) end -- The one formatter. `sign` is the leading plus a change wants and a price does -- not; a missing value arrives as false, the ring's way of saying no average. local function num(v, d, sign) if not v then return "--" end return string.format("%" .. (sign and "+" or "") .. "." .. d .. "f", v) end -- Two thresholds rather than four: a billion and a million are the two a quote -- reaches, and the rest is printed whole. -- No calendar date and no company name: dates need the civil-from-days -- arithmetic there is no os library for, and staleness reads CACHED instead. local function sv(c, f) return S[((Shead + c - 1) % COLS) * 3 + f] end -- --- persistence -- One blob of at most 1024 bytes, so the series is not in it: thirty closes a -- symbol would fill it on their own. -- VERSION 3 IS THIS FORMAT, and 1 and 2 are the two before it. THE SYMBOL LIST -- IS READ OUT OF ALL THREE: the owner spelt it in by hand on a wheel, and a -- card that forgot it on an upgrade would cost him the only part he typed. -- Cached quotes are not carried across — the older formats hold other fields -- in other orders, and a price read out of the wrong slot is a wrong number -- rather than an absent one — so a migrated row reads "--" until fetched. -- (Version 2 spelled its three defaults the way stooq does; those arrive as -- they were and are removed by hand, which the editor exists for. The table -- that renamed them bought back the memory the chart's step line costs.) local function save() local out = {"3", table.concat(list, ",")} for i = 1, #list do out[i + 2] = Q[i] or "-" end local blob = table.concat(out, "|") -- Cut at a field boundary: half a quote parses as a whole one. if #blob > 1024 then blob = blob:sub(1, 1024):match("^(.*)|") or "3" end snail.save(blob) end local function load() local blob = snail.load() if not blob then return end local ver, n = nil, 0 for p in blob:gmatch("[^|]+") do n = n + 1 if n == 1 then ver = p if ver ~= "1" and ver ~= "2" and ver ~= "3" then return end elseif n == 2 then local fresh = {} for s in p:gmatch("[^,]+") do if #fresh < MAX_SYMS then fresh[#fresh + 1] = s end end if #fresh > 0 then list = fresh end elseif ver == "3" and n - 2 <= #list and p ~= "-" then -- A row with no quote was written as a dash, because gmatch skips an -- empty field and one skipped field shifts every quote after it. Q[n - 2] = p end end end -- --- the chrome -- The status band is a fixed reserve the kernel drops when there is no status, -- which would give the chart a different tile size, so there is always one. local HINT = { home = "OK open BACK menu", sym = "LEFT back OK Fetch RIGHT Page", -- A fourth entry wrapped the footer; 2x SIDE still works, and home names it. edit = "LEFT back OK add RIGHT remove BACK menu", add = "L/R slot OK add BACK menu", } local function chrome() snail.header(view == "sym" and (list[sel] or "Stocks") or "Stocks") local st if view == "sym" then st = not Q[sel] and "no data yet" or (L[sel] and "delayed ~15 min" or "CACHED - not refreshed yet") elseif view == "edit" then st = armed and "RIGHT again removes it" or "editing the watchlist" elseif view == "add" then st = "spelling" else st = #list .. " on the watchlist" end snail.status(msg ~= "" and msg or st) snail.hint(HINT[view]) end -- --- fetch -- THE FOUR SCALARS AND THE SERIES, FROM ONE REQUEST. The names before `close[]` -- are read out of the meta block wherever it sits in the reply, and the array -- after them is delivered to the sink one close at a time. Nothing larger than -- one number is ever a Lua value, so the worst instant here is the ring of -- thirty columns rather than the ring plus a six-kilobyte document. A fetch is -- started from key() and never from draw(), which runs twice a press; a refresh -- goes through snail.after(), so the press is answered before the radio is. local FIELDS = "regularMarketPrice,chartPreviousClose,close[]" -- ONE CLOSE, AS IT ARRIVES. This is the sink itself, so a number goes from the -- wire into the ring without a Lua value living longer than the call. Both -- running sums evict the value that has just left their window by reading it -- back out of the display ring rather than out of a second buffer, which works -- because both periods are shorter than the ring. RSI's first value is the plain -- average of the first RSI_N changes and every one after it smooths the previous -- with the new change, which is the recursion that makes this Wilder's RSI. No -- losses would divide by zero, so that case reads 100, the limit it approaches. -- -- A bar that did not trade arrives as nil and the previous close is carried -- forward: dropping one shortens the series, and a zero spikes the chart and -- drags both averages through it. The engine hands over a bare JSON number as a -- number; tonumber() is here for the day a vendor quotes them instead. local function step(tok) local v = tonumber(tok) or Sprev if not v then return end local n = Sn + 1 Sn = n if n > MA_SLOW then Sslow = Sslow - S[((n - MA_SLOW - 1) % COLS) * 3 + 1] end if n > MA_FAST then Sfast = Sfast - S[((n - MA_FAST - 1) % COLS) * 3 + 1] end Sfast, Sslow = Sfast + v, Sslow + v Sback, Sprev = Sprev, v local o = ((n - 1) % COLS) * 3 S[o + 1] = v S[o + 2] = n >= MA_FAST and Sfast / MA_FAST or false S[o + 3] = n >= MA_SLOW and Sslow / MA_SLOW or false end local function series_key(sym) return sym .. ":" .. period_name() end local function reset_series() Sn, Sfast, Sslow, Sprev, Sback = 0, 0, 0, 0, 0 end local function remember(sym) local out = {Q[sel] or "-"} for c = 1, Scols do out[c + 1] = tostring(sv(c, 1)) end snail.cache(series_key(sym), table.concat(out, "|")) end local function recall() local sym = list[sel] if not sym then return false end -- Release the previous period before asking Lua to allocate the saved copy. -- The card API enforces the same memory ceiling on cache reads as fetches. drop() reset_series() local saved = snail.cache(series_key(sym)) if not saved then return false end local quote, closes = saved:match("^([^|]*)|(.*)$") if not quote or quote == "-" then return false end for value in closes:gmatch("[^|]+") do step(value) end if Sn < 2 then drop() return false end Ssym, Scols, Shead = series_key(sym), math.min(Sn, COLS), 0 Q[sel], L[sel] = quote, nil return true end local function fetch() local i = sel local sym = list[i] if not sym then return end -- No "fetching" status: this runs inside snail.after, and nothing paints -- again before it returns, when msg is already overwritten. -- THE SERIES GOES BEFORE THE REQUEST DOES when the symbol is not the one the -- ring holds, so that two symbols' closes are never in memory at once. A -- refresh of the symbol on screen keeps its chart, so a refresh that never -- ran the sink leaves the last one that worked. local key = series_key(sym) if Ssym ~= key then drop() end reset_series() -- The names in front of the array are read out of the reply's meta block and -- come back as a small table of strings; the closes go straight to step(). local head, why = snail.fetch(HOST .. sym:gsub("%^", "%%5E") .. period_tail(), FIELDS, step) local price = head and tonumber(head.regularMarketPrice) if not price or Sn < 2 then -- A parsed reply ran the sink over a kept ring, so the kept chart no -- longer matches Scols and Shead and goes with it. if head and Ssym then drop() end msg = head and "no price in the reply" or why or "no answer" return chrome() end -- THE PREVIOUS CLOSE IS NOT A FIELD HERE. meta carries chartPreviousClose, -- the close before the whole range rather than yesterday's, so it comes out of -- the series, and which bar depends on whether the last is today's unfinished -- one. Comparing them stops a stale change all weekend. local eps = math.max(0.01, math.abs(price) * 1e-5) local livebar = math.abs(Sprev - price) < eps local series_pc = livebar and Sback or Sprev local pc = (period == 1 and tonumber(head.chartPreviousClose)) or series_pc or price -- The chart should end on the number printed above it, so the live price -- becomes one more column when the series does not already carry it. if not livebar then step(price) end Ssym = key Scols = math.min(Sn, COLS) Shead = Sn > COLS and Sn % COLS or 0 -- The row and the page are both written here, once, so that no frame has to -- work out what a quote looks like while it is being drawn. local dp = places(price) local d = price - pc local pt = num(price, dp) local pf = pc ~= 0 and string.format("%+.2f%%", d / pc * 100) or "--" Q[i] = string.format("%-9s %s %s %s", sym, pt, (d > 0 and "^") or (d < 0 and "v") or "=", pf) L[i] = true msg = "" save() remember(sym) -- Without this, a quote fetched a moment ago still wears the CACHED band. chrome() end local function blank() for i = 1, SLOTS do buf[i] = #WHEEL end end local function spelled() local out = {} for i = 1, SLOTS do out[i] = WHEEL:sub(buf[i], buf[i]) end -- Every space goes: "B TC" is a URL with a hole in it. return (table.concat(out):gsub(" ", "")) end -- --- the plot -- CAN snail.board() CARRY A CHART. A board is a grid of up to 32 by 32 tiles, -- each taking one of five silhouettes: a quantised plot carrying three series -- told apart by shape because the panel is one bit. The close is joined into a -- step line — every column also fills to the row where the one before it ended -- — so it reads as a line; the averages stay one mark per column. -- -- A SERIES BECOMES ONE ROW NUMBER PER COLUMN, added into `out` at digit `mul`. -- `k`, when given, is drawn across every column, which is how RSI's guide -- lines share its scale. The first pass (mul of 1) is the close, and each of -- its columns becomes a run reaching the row the one before it ended on. local function rows_of(f, lo, hi, rows, out, k, mul) local span = hi - lo if span <= 0 then span = 1 end local p for c = 1, Scols do local v, r = k or sv(c, f), 0 if v then r = rows - math.floor((v - lo) / span * (rows - 1) + 0.5) if r < 1 then r = 1 elseif r > rows then r = rows end end if mul == 1 then if r > 0 then local q = p or r p = r r = (q < r and q or r) * 100 + (q < r and r or q) end out[c] = r else out[c] = out[c] + r * mul end end end -- The close wins the cell, so it is never hidden. Its run sits in the low four -- digits as top * 100 + bottom, the averages two digits each above it, 0 for -- absent: three arrays of thirty crossed 70% of memory. local function board(rows, r1, c2, c3) local out, line = {}, {} for r = 1, rows do for c = 1, Scols do local v = r1[c] local a = v % 10000 line[c] = (a > 0 and r >= a // 100 and r <= a % 100 and "#") or (v // 10000 % 100 == r and c2) or (v // 1000000 == r and c3) or "." end out[r] = table.concat(line) end return table.concat(out, "/") end -- --- the symbol local function draw_sym() local s = list[sel] if not s then snail.title("Nothing selected") snail.text("The watchlist is empty. LEFT goes back to it.") return end local mine = Ssym == series_key(s) and Scols > 1 snail.small(period_name()) if not mine then snail.title("No series") snail.gap() snail.text("Press OK to load the history for " .. s .. ".") snail.small("Day, Month and Quarter are kept separately on the card.") return end local r1 = {} local rows, lo, hi = CHART_ROWS, sv(1, 1), sv(1, 1) local dp = places(sv(Scols, 1)) for c = 1, Scols do for f = 1, 3 do local v = sv(c, f) if v then if v < lo then lo = v elseif v > hi then hi = v end end end end snail.small("high " .. num(hi, dp) .. " low " .. num(lo, dp)) -- The legend and graph go through the same board renderer, so each close, -- MA10 and MA20 marker has identical pixels in both places. snail.board("#.........*.........o.........") snail.small("close MA" .. MA_FAST .. " MA" .. MA_SLOW) snail.rule() rows_of(1, lo, hi, rows, r1, nil, 1) rows_of(2, lo, hi, rows, r1, nil, 10000) rows_of(3, lo, hi, rows, r1, nil, 1000000) snail.board(board(rows, r1, "*", "o")) end -- --- the app function start() load() if #list == 0 then list = {"^GSPC"} end sel, period, esel, view, armed, msg = 1, 1, 1, "home", nil, "" blank() chrome() end -- One handler for four views. The watchlist and its editor are the same list -- with a different cursor and a different RIGHT, so they share a branch. function key(k) -- UP and DOWN mean the same thing in all four views, a step that wraps at both -- ends, so the direction is worked out once and each view says only what it -- steps through. The wheel turns the other way: UP raises the letter. local d = (k == "down" and 1) or (k == "up" and -1) or 0 if view == "add" then if d ~= 0 then buf[bpos] = (buf[bpos] - 1 - d) % #WHEEL + 1 elseif k == "right" then bpos = bpos < SLOTS and bpos + 1 or SLOTS elseif k == "left" then if bpos > 1 then bpos = bpos - 1 else view, msg = "edit", "" end elseif k == "ok" then local s = spelled() local dup = false for _, e in ipairs(list) do if e == s then dup = true end end if s == "" then msg = "nothing spelled yet" elseif dup then msg = s .. " is already on the list" elseif #list >= MAX_SYMS then msg = "the watchlist holds " .. MAX_SYMS else list[#list + 1] = s save() msg, view, esel = "added " .. s, "edit", #list end end elseif view == "sym" then if k == "left" then view, msg = "home", "" elseif k == "right" then period = period % 3 + 1 msg = "" if not recall() then snail.after(fetch) end elseif d ~= 0 then if #list > 0 then sel = (sel - 1 + d) % #list + 1 end msg = "" if not recall() then snail.after(fetch) end elseif k == "ok" or k == "top" then snail.after(fetch) end else local edit = view == "edit" local rows = #list + 1 -- the last row edits, or spells if d ~= 0 then armed = nil local cur = (((edit and esel or sel) - 1 + d) % rows) + 1 if edit then esel = cur else sel = cur end elseif k == "left" then if edit then view, armed, msg = "home", nil, "" if sel > #list then sel = math.max(1, #list) end end elseif k == "top" then -- To the first row. The watchlist is the owner's own and lives on the -- card, and each quote is its own request made when a symbol is opened, -- so there is no list to ask for again and the footer says so. armed = nil if edit then esel = 1 else sel, period = 1, 1 end elseif edit and k == "right" and esel <= #list then if armed == esel then -- The cached quote goes with the symbol rather than sitting in the blob -- as a price for a row nobody can see. The parallel arrays are shifted -- by hand, because a row never fetched leaves a hole and a table with a -- hole in it has no length. local n = #list local gone = list[esel] table.remove(list, esel) for j = esel, n - 1 do Q[j], L[j] = Q[j + 1], L[j + 1] end Q[n], L[n] = nil, nil if Ssym and Ssym:sub(1, #gone + 1) == gone .. ":" then drop() end armed, msg = nil, "removed" if esel > #list then esel = math.max(1, #list) end if sel > #list then sel = math.max(1, #list) end save() else armed = esel end elseif k == "ok" or k == "right" then armed = nil if edit then if esel > #list then view, bpos, msg = "add", 1, "" blank() end elseif sel > #list then view, msg = "edit", "" esel = math.min(esel, #list + 1) else period, view, msg = 1, "sym", "" if not recall() then snail.after(fetch) end end end end chrome() end -- The watchlist and its editor are one screen drawn twice: prices on the rows -- in one, what RIGHT would do in the other. function draw() if view == "sym" then return draw_sym() end if view == "add" then snail.center(true) snail.title("Add a symbol") snail.gap() local out = {} for i = 1, SLOTS do local c = WHEEL:sub(buf[i], buf[i]) if c == " " then c = "_" end out[i] = (i == bpos) and ("[" .. c .. "]") or (" " .. c .. " ") end snail.title(table.concat(out)) snail.gap() local s = spelled() snail.text(s == "" and "(nothing yet)" or s) snail.gap() snail.small("An index carries its caret: ^GSPC. A coin is BTC-USD.") snail.small(msg) return end -- The house list: rows and nothing else, with one small scope line where the -- editor names itself. The action row wears the settings glyph. local edit = view == "edit" if #list == 0 then snail.center(true) snail.gap() snail.text("The watchlist is empty.") snail.small("Open the editor below and spell a symbol.") snail.center(false) end local cur = edit and esel or sel for i, s in ipairs(list) do local q = not edit and Q[i] snail.row(edit and ((armed == i and "remove " or "") .. s) or (q and q:match("^[^\t]*") or (s .. " --")), i == cur, "chart") end snail.row(edit and "Add a symbol" or "Edit watchlist", cur > #list, "settings") end