Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // Variables used by Scriptable.
- // These must be at the very top of the file. Do not edit.
- // icon-color: deep-purple; icon-glyph: chart-line;
- // SPY 200SMA strategy for TQQQ
- // BUY SPY closes above its 200SMA +4% -> TQQQ
- // SELL SPY closes below its 200SMA -3% -> QQQ
- // GUARD1 QQQ closes 30%+ above its own 200SMA -> warning only
- // GUARD2 QQQ closes 40%+ above its own 200SMA -> cash; TQQQ again on
- // the first BUY after a SELL
- // Signals are read on daily closes; an open session is shown but never acts.
- const DEFAULTS = { entry: 4, exit: 3, g1: 30, g2: 40, range: "1y" };
- const PUBLISHED = { entry: 4, exit: 3, g1: 30, g2: 40 };
- const RANGES = { "6m": 126, "1y": 252, "2y": 504 };
- const SMA_LEN = 200;
- const SETTINGS_FILE = "spy200sma-settings.json";
- // History is downloaded once and kept on the phone; each refresh then fetches
- // only the last days, which also carry the live price.
- const CACHE_FILE = "spy200sma-history.json";
- // What you hold depends on the last signal. The first download is 3 years and is
- // widened only while that signal is out of reach (no state has lasted longer
- // than 3.6 years since 1999).
- const BACKFILL_YEARS = [3, 6, 30];
- const RECENT_DAYS = 10;
- const YEAR = 365.25 * 864e5;
- const HOLDING = { LEVERED: "TQQQ", OUT: "QQQ", CASH: "CASH" };
- // ------------------------------------------------------------------ logic
- function settings(saved = {}) {
- const s = { ...DEFAULTS, ...saved };
- if (!(s.range in RANGES)) s.range = DEFAULTS.range;
- return { ...s, days: RANGES[s.range] };
- }
- function sma(xs, len) {
- const out = new Array(xs.length).fill(NaN);
- let sum = 0;
- for (let i = 0; i < xs.length; i++) {
- sum += xs[i];
- if (i >= len) sum -= xs[i - len];
- if (i >= len - 1) out[i] = sum / len;
- }
- return out;
- }
- // The rule as a state machine, replayed from the first bar, so the state never
- // depends on when the widget last ran. GUARD2 locks out TQQQ until a SELL.
- function replay(spy, spySma, qqqExt, p) {
- const entry = p.entry / 100, exit = p.exit / 100, g2line = p.g2 / 100;
- let state = "OUT", locked = false;
- const states = new Array(spy.length), locks = new Array(spy.length);
- for (let i = 0; i < spy.length; i++) {
- if (!isNaN(spySma[i])) {
- const buy = spy[i] > spySma[i] * (1 + entry);
- const sell = spy[i] < spySma[i] * (1 - exit);
- const g2 = qqqExt[i] >= g2line;
- if (sell) locked = false;
- if (state === "LEVERED") {
- if (sell) state = "OUT";
- else if (g2) { state = "CASH"; locked = true; }
- } else if (state === "CASH") {
- if (buy && !locked && !g2) state = "LEVERED";
- } else if (buy && !g2) {
- state = "LEVERED";
- }
- }
- states[i] = state;
- locks[i] = locked;
- }
- return { states, locks };
- }
- // A 200-bar average is a 200-DAY average only if the bars are days. Yahoo can
- // return monthly bars without saying so; refuse them rather than draw a signal.
- function checkDaily(bars, ticker) {
- const t = bars.slice(-61).map(b => Date.parse(b.date) / 864e5);
- const gaps = t.slice(1).map((x, i) => x - t[i]).sort((a, b) => a - b);
- const median = gaps[Math.floor(gaps.length / 2)];
- // Daily bars across a holiday can sit 2-3 days apart; weekly ones sit 7.
- if (median > 4) throw new Error(`${ticker}: bars are ${median} days apart, not daily`);
- }
- // Stored [date, close] pairs with new bars merged in; a date already stored is
- // overwritten, so a revised close replaces the old one.
- function merge(pairs, bars) {
- const m = new Map(pairs);
- bars.forEach(b => m.set(b.date, b.close));
- return [...m].sort((a, b) => (a[0] < b[0] ? -1 : 1));
- }
- function rowsOf(h) {
- const q = new Map(h.qqq);
- return h.spy.filter(([date]) => q.has(date)).map(([date, spy]) => ({ date, spy, qqq: q.get(date) }));
- }
- function compute(rows, p) {
- const spy = rows.map(r => r.spy), qqq = rows.map(r => r.qqq);
- const spySma = sma(spy, SMA_LEN), qqqSma = sma(qqq, SMA_LEN);
- const qqqExt = qqq.map((x, i) => x / qqqSma[i] - 1);
- const { states, locks } = replay(spy, spySma, qqqExt, p);
- const n = rows.length - 1;
- let since = n;
- while (since > 0 && states[since - 1] === states[n]) since--;
- // The replay starts blind. After a SELL day and then a BUY day every possible
- // past agrees, GUARD2 cash included, so the state and its start are known only
- // if that BUY came no later than the start.
- const isSell = i => spy[i] < spySma[i] * (1 - p.exit / 100);
- const isBuy = i => spy[i] > spySma[i] * (1 + p.entry / 100) && qqqExt[i] < p.g2 / 100;
- let first = 0;
- while (first <= n && !isSell(first)) first++;
- while (first <= n && !isBuy(first)) first++;
- const from = Math.max(0, rows.length - RANGES["2y"]);
- return {
- date: rows[n].date, first: rows[0].date, bars: rows.length,
- spy: spy[n], sma: spySma[n], qqqExt: qqqExt[n],
- buyLine: spySma[n] * (1 + p.entry / 100), sellLine: spySma[n] * (1 - p.exit / 100),
- state: states[n], locked: locks[n], since: rows[since].date, settled: first <= since,
- history: rows.slice(from).map((r, k) => ({ spy: spy[from + k], sma: spySma[from + k], state: states[from + k] })),
- };
- }
- // The opposite signal: the line that ends what you hold. SELL while in TQQQ,
- // BUY while in QQQ or unlocked cash, and the unlocking SELL after GUARD2.
- function nextTrigger(d, p, price) {
- const sell = { name: "SELL", line: d.sellLine, gap: d.sellLine / price - 1 };
- const buy = { name: "BUY", line: d.buyLine, gap: d.buyLine / price - 1 };
- if (d.state === "LEVERED") return sell;
- if (d.state === "CASH" && d.locked) return { ...sell, unlock: true };
- return buy;
- }
- // Fixed scale for the gauges, so SELL, SMA and BUY always sit in the same place.
- const gaugeScale = p => ({ lo: -(p.exit + 9) / 100, hi: (p.entry + 10) / 100 });
- // ------------------------------------------------------------------- data
- function chartURL(host, ticker, from, now = Date.now()) {
- // period1/period2, never range=max: for range=max Yahoo returns monthly bars.
- return `https://${host}.finance.yahoo.com/v8/finance/chart/${ticker}` +
- `?period1=${Math.floor(from / 1000)}&period2=${Math.floor(now / 1000) + 86400}&interval=1d&includePrePost=false`;
- }
- async function fetchBars(ticker, from) {
- let lastErr;
- for (const host of ["query1", "query2"]) {
- try {
- const req = new Request(chartURL(host, ticker, from));
- req.headers = { "User-Agent": "Mozilla/5.0" };
- req.timeoutInterval = 20;
- const r = (await req.loadJSON()).chart.result[0];
- const close = r.indicators.quote[0].close;
- const bars = [];
- r.timestamp.forEach((t, i) => {
- if (close[i] != null) bars.push({ date: new Date(t * 1000).toISOString().slice(0, 10), close: close[i] });
- });
- checkDaily(bars, ticker);
- return bars;
- } catch (e) {
- lastErr = e;
- }
- }
- throw lastErr;
- }
- function nowET() {
- const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
- timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit",
- hour: "2-digit", minute: "2-digit", hour12: false,
- }).formatToParts(new Date()).map(x => [x.type, x.value]));
- const hm = (parseInt(parts.hour, 10) % 24) * 60 + parseInt(parts.minute, 10);
- return { date: `${parts.year}-${parts.month}-${parts.day}`, closed: hm >= 16 * 60 + 5 };
- }
- function files() {
- const fm = FileManager.local();
- const at = name => fm.joinPath(fm.documentsDirectory(), name);
- return {
- read(name) {
- try { return fm.fileExists(at(name)) ? JSON.parse(fm.readString(at(name))) : null; } catch (e) { return null; }
- },
- write(name, value) { fm.writeString(at(name), JSON.stringify(value)); },
- };
- }
- async function load(p) {
- const store = files(), et = nowET();
- const h = store.read(CACHE_FILE) || { years: 0, spy: [], qqq: [] };
- // Only completed sessions are kept: the rule reads closes. Today's bar while
- // the market is open is the live price, shown but never stored.
- const closed = b => b.date < et.date || et.closed;
- let live = null, error = null;
- const get = async from => {
- const [s, q] = await Promise.all([fetchBars("SPY", from), fetchBars("QQQ", from)]);
- h.spy = merge(h.spy, s.filter(closed));
- h.qqq = merge(h.qqq, q.filter(closed));
- const open = s.find(b => !closed(b));
- if (open) live = open.close;
- h.fetched = Date.now();
- };
- try {
- if (!h.years) {
- await get(Date.now() - BACKFILL_YEARS[0] * YEAR);
- h.years = BACKFILL_YEARS[0];
- }
- // Back past the last stored close too, so a phone that was off leaves no gap.
- const last = h.spy.length ? Date.parse(h.spy[h.spy.length - 1][0]) : Date.now();
- await get(Math.min(Date.now() - RECENT_DAYS * 864e5, last - 5 * 864e5));
- } catch (e) {
- if (!h.spy.length) throw e;
- error = String(e.message || e);
- }
- const evaluate = () => {
- const rows = rowsOf(h);
- if (rows.length < SMA_LEN + 60) throw new Error(`only ${rows.length} daily closes`);
- return compute(rows, p);
- };
- let d = evaluate();
- while (!d.settled && !error && BACKFILL_YEARS.some(y => y > h.years)) {
- const wider = BACKFILL_YEARS.find(y => y > h.years);
- try {
- await get(Date.now() - wider * YEAR);
- h.years = wider;
- } catch (e) {
- error = String(e.message || e);
- }
- d = evaluate();
- }
- store.write(CACHE_FILE, h);
- if (!(Math.abs(d.spy / d.sma - 1) < 0.6)) throw new Error(`SPY ${d.spy} vs 200SMA ${d.sma.toFixed(2)} is implausible`);
- return { ...d, live, fetched: h.fetched, stale: !!error, error };
- }
- // ----------------------------------------------------------------- drawing
- //
- // Colours sampled from Apple's Stocks widget on a dark home screen. Always
- // dark: drawn images cannot follow light/dark mode.
- const hex = (h, a = 1) => new Color(h, a);
- const C = {
- white: hex("#ffffff"), text2: hex("#979799"), text3: hex("#979799", 0.8),
- green: hex("#41be5f"), red: hex("#e4433b"), amber: hex("#f5a623"),
- magenta: hex("#ff4fd8"), purple: hex("#bf5af2"),
- };
- const ACCENT = { LEVERED: C.purple, OUT: C.amber, CASH: C.text2 };
- const pct = (x, d = 1) => `${x >= 0 ? "+" : "−"}${Math.abs(x * 100).toFixed(d)}%`;
- const px = x => x.toFixed(2);
- const parseISO = iso => { const [y, m, d] = iso.split("-").map(Number); return new Date(y, m - 1, d); };
- const daysBetween = (a, b) => Math.round((parseISO(b) - parseISO(a)) / 864e5);
- const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
- // Height a line of drawn text needs: iOS puts the baseline ~0.95em down.
- const lineH = size => Math.ceil(size * 1.25) + 1;
- const gaugeHeight = prices => 16 + lineH(8) + (prices ? lineH(8) : 0);
- function canvas(w, h) {
- const ctx = new DrawContext();
- ctx.size = new Size(w, h);
- ctx.opaque = false;
- ctx.respectScreenScale = true;
- return ctx;
- }
- function stroke(ctx, pts, color, width) {
- const path = new Path();
- path.addLines(pts);
- ctx.addPath(path);
- ctx.setStrokeColor(color);
- ctx.setLineWidth(width);
- ctx.strokePath();
- }
- function fill(ctx, pts, color) {
- const path = new Path();
- path.addLines(pts);
- path.closeSubpath();
- ctx.addPath(path);
- ctx.setFillColor(color);
- ctx.fillPath();
- }
- function rounded(ctx, x, y, w, h, color) {
- const path = new Path();
- path.addRoundedRect(new Rect(x, y, Math.max(w, 0), h), h / 2, h / 2);
- ctx.addPath(path);
- ctx.setFillColor(color);
- ctx.fillPath();
- }
- function label(ctx, s, x, y, w, size, color, align = "center") {
- ctx.setFont(Font.semiboldSystemFont(size));
- ctx.setTextColor(color);
- if (align === "left") ctx.setTextAlignedLeft();
- else ctx.setTextAlignedCenter();
- ctx.drawTextInRect(s, new Rect(align === "left" ? x : x - w / 2, y, w, size * 1.4));
- }
- function dot(ctx, x, y, r) {
- ctx.setFillColor(hex("#000000", 0.6));
- ctx.fillEllipse(new Rect(x - r - 1.5, y - r - 1.5, 2 * r + 3, 2 * r + 3));
- ctx.setFillColor(C.white);
- ctx.fillEllipse(new Rect(x - r, y - r, 2 * r, 2 * r));
- }
- // SPY against its SMA: red below SELL, green above BUY, magenta tick at the SMA.
- function gauge(d, p, price, w, { prices = false } = {}) {
- const ext = price / d.sma - 1, { lo, hi } = gaugeScale(p);
- const pad = 7, ty = 6, th = 6, ctx = canvas(w, gaugeHeight(prices));
- const X = e => pad + ((clamp(e, lo, hi) - lo) / (hi - lo)) * (w - 2 * pad);
- const xs = X(-p.exit / 100), xb = X(p.entry / 100), xm = X(0);
- const zone = c => hex(c, 0.9);
- rounded(ctx, pad - th / 2, ty, w - 2 * pad + th, th, hex("#ffffff", 0.16));
- rounded(ctx, pad - th / 2, ty, xs - pad + th / 2, th, zone("#e4433b"));
- rounded(ctx, xb, ty, w - pad + th / 2 - xb, th, zone("#41be5f"));
- ctx.setFillColor(C.magenta);
- ctx.fillRect(new Rect(xm - 1, ty - 3, 2, th + 6));
- dot(ctx, X(ext), ty + th / 2, 5);
- const cy = ty + th / 2; // off the scale: an arrow at that end
- if (ext > hi) fill(ctx, [new Point(w - 6, cy - 4), new Point(w - 1, cy), new Point(w - 6, cy + 4)], C.white);
- if (ext < lo) fill(ctx, [new Point(6, cy - 4), new Point(1, cy), new Point(6, cy + 4)], C.white);
- const dim = C.text3, y2 = 16 + lineH(8);
- label(ctx, "SELL", xs, 16, 40, 8, C.red);
- label(ctx, "BUY", xb, 16, 40, 8, C.green);
- if (prices) {
- label(ctx, "SMA", xm, 16, 40, 8, C.magenta);
- label(ctx, px(d.sellLine), xs, y2, 50, 8, dim);
- label(ctx, px(d.sma), xm, y2, 50, 8, dim);
- label(ctx, px(d.buyLine), xb, y2, 50, 8, dim);
- }
- return ctx.getImage();
- }
- // SPY, its SMA and the two lines, with a ribbon underneath of what the rule
- // held: purple TQQQ, amber QQQ, grey cash.
- function chart(d, p, w, h, names = false) {
- const hist = d.history.slice(-p.days).filter(b => !isNaN(b.sma));
- const end = hist[hist.length - 1];
- if (end && d.live != null) hist.push({ ...end, spy: d.live }); // the line ends at the live price
- const ctx = canvas(w, h);
- if (hist.length < 2) return ctx.getImage();
- const up = 1 + p.entry / 100, dn = 1 - p.exit / 100, ph = h - 8;
- const vals = hist.flatMap(b => [b.spy, b.sma * up, b.sma * dn]);
- const lo = Math.min(...vals), hi = Math.max(...vals), padY = (hi - lo) * 0.06;
- const X = i => 1 + (i / (hist.length - 1)) * (w - 8);
- const Y = v => ph - 2 - ((v - lo + padY) / (hi - lo + 2 * padY)) * (ph - 8);
- const line = f => hist.map((b, i) => new Point(X(i), Y(f(b))));
- fill(ctx, [...line(b => b.sma * up), ...line(b => b.sma * dn).reverse()], hex("#ffffff", 0.06));
- fill(ctx, [new Point(X(0), ph), ...line(b => b.spy), new Point(X(hist.length - 1), ph)], hex("#ffffff", 0.05));
- stroke(ctx, line(b => b.sma * up), hex("#41be5f", 0.85), 1);
- stroke(ctx, line(b => b.sma * dn), hex("#e4433b", 0.85), 1);
- stroke(ctx, line(b => b.sma), hex("#ff4fd8", 0.95), 1.25);
- stroke(ctx, line(b => b.spy), C.white, 1.5);
- const lx = X(hist.length - 1), ly = Y(hist[hist.length - 1].spy);
- ctx.setFillColor(hex("#ffffff", 0.25));
- ctx.fillEllipse(new Rect(lx - 6, ly - 6, 12, 12));
- ctx.setFillColor(C.white);
- ctx.fillEllipse(new Rect(lx - 3, ly - 3, 6, 6));
- const col = { LEVERED: "#bf5af2", OUT: "#f5a623", CASH: "#8e8e93" };
- let start = 0;
- hist.forEach((b, i) => {
- if (i < hist.length - 1 && hist[i + 1].state === b.state) return;
- rounded(ctx, X(start), h - 3, Math.max(3, X(i) - X(start) - 1.5), 3, hex(col[b.state], 0.95));
- start = i + 1;
- });
- if (names) {
- const f = hist[0];
- label(ctx, "BUY", 2, clamp(Y(f.sma * up) - 12, 0, ph - lineH(8)), 40, 8, C.green, "left");
- label(ctx, "SELL", 2, clamp(Y(f.sma * dn) + 2, 0, ph - lineH(8)), 40, 8, C.red, "left");
- }
- return ctx.getImage();
- }
- // QQQ over its own SMA against the two guard lines.
- function guardMeter(d, p, w) {
- const top = p.g2 + 10, ctx = canvas(w, 22), e = d.qqqExt * 100;
- const X = v => (clamp(v, 0, top) / top) * w;
- rounded(ctx, 0, 4, w, 4, hex("#ffffff", 0.16));
- if (e > 0) rounded(ctx, 0, 4, Math.max(4, X(e)), 4, guardColor(d, p));
- for (const [g, c] of [[p.g1, C.amber], [p.g2, C.red]]) {
- ctx.setFillColor(c);
- ctx.fillRect(new Rect(X(g) - 1, 1, 2, 10));
- label(ctx, `${g}%`, X(g), 12, 30, 8, C.text3);
- }
- return ctx.getImage();
- }
- // ------------------------------------------------------------------ layout
- function text(stack, s, font, color, scale = 0.6) {
- const t = stack.addText(s);
- t.font = font;
- t.textColor = color;
- t.lineLimit = 1;
- t.minimumScaleFactor = scale;
- return t;
- }
- function stat(stack, f, size = 13) {
- const s = stack.addStack();
- s.layoutVertically();
- text(s, f.caption.toUpperCase(), Font.semiboldSystemFont(8), C.text3);
- s.addSpacer(1);
- text(s, f.value, Font.semiboldSystemFont(size), f.color);
- return s;
- }
- function image(stack, img, w, h) {
- stack.addImage(img).imageSize = new Size(w, h);
- }
- const caption = (stack, s) => text(stack, s, Font.boldSystemFont(10), C.text3);
- const guardColor = (d, p) => (d.qqqExt * 100 >= p.g2 ? C.red : d.qqqExt * 100 >= p.g1 ? C.amber : C.white);
- const side = (d, price) => `${price >= d.sma ? "over" : "under"} 200SMA`;
- // Shown only when Yahoo failed and the reading is an old one.
- function stamp(d) {
- return `Stale since ${new Date(d.fetched).toLocaleString("en-US",
- { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
- }
- // The headline is SPY over or under its SMA; what you hold and the distance to
- // the opposite signal sit underneath as stats.
- function facts(d, p, price) {
- const t = nextTrigger(d, p, price), ext = price / d.sma - 1;
- const extColor = ext >= 0 ? C.green : C.red;
- const days = daysBetween(d.since, d.date);
- const holding = { caption: `Held ${days}d`, value: HOLDING[d.state], color: ACCENT[d.state] };
- const spy = { value: pct(ext), color: extColor, sub: side(d, price) };
- const signal = { caption: t.unlock ? "to unlock" : `to ${t.name}`, value: pct(t.gap), color: C.white };
- return { lead: spy, rest: [holding, signal] };
- }
- function hero(stack, f, size) {
- text(stack, f.value, Font.boldSystemFont(size), f.color, 0.5);
- text(stack, f.sub, Font.semiboldSystemFont(11), C.text2);
- }
- function pair(stack, a, b) {
- const r = stack.addStack();
- r.layoutHorizontally();
- r.bottomAlignContent();
- stat(r, a);
- r.addSpacer(6);
- r.addSpacer();
- stat(r, b);
- }
- function background(w) {
- const g = new LinearGradient();
- g.colors = [new Color("#1f1f1f"), new Color("#0d0d0d")];
- g.locations = [0, 1];
- g.startPoint = new Point(0.5, 0);
- g.endPoint = new Point(0.5, 1);
- w.backgroundGradient = g;
- }
- function buildSmall(w, d, p, price) {
- const { lead, rest } = facts(d, p, price);
- w.setPadding(14, 14, 12, 14);
- const top = w.addStack();
- caption(top, "SPY 200SMA");
- if (d.stale) {
- top.addSpacer();
- text(top, "Stale", Font.boldSystemFont(10), C.amber);
- }
- w.addSpacer(3);
- hero(w, lead, 32);
- w.addSpacer();
- pair(w, rest[0], rest[1]);
- w.addSpacer(6);
- image(w, gauge(d, p, price, 130), 130, gaugeHeight(false));
- }
- function buildMedium(w, d, p, price) {
- const { lead, rest } = facts(d, p, price);
- w.setPadding(14, 14, 12, 14);
- const row = w.addStack();
- const left = row.addStack();
- left.layoutVertically();
- left.size = new Size(124, 132);
- caption(left, "SPY 200SMA");
- left.addSpacer(3);
- hero(left, lead, 30);
- left.addSpacer();
- pair(left, rest[0], rest[1]);
- if (d.stale) {
- left.addSpacer(3);
- text(left, stamp(d), Font.mediumSystemFont(9), C.amber);
- }
- row.addSpacer(10);
- const right = row.addStack();
- right.layoutVertically();
- image(right, chart(d, p, 176, 92), 176, 92);
- right.addSpacer(8);
- image(right, gauge(d, p, price, 176), 176, gaugeHeight(false));
- }
- function buildLarge(w, d, p, price) {
- const { lead, rest } = facts(d, p, price);
- w.setPadding(16, 16, 14, 16);
- const top = w.addStack();
- top.centerAlignContent();
- for (const s of ["SPY 200SMA", `+${p.entry}/−${p.exit}`, p.range.toUpperCase()]) {
- caption(top, s);
- top.addSpacer(8);
- }
- if (d.stale) {
- top.addSpacer();
- text(top, stamp(d), Font.mediumSystemFont(10), C.amber);
- }
- w.addSpacer(4);
- const head = w.addStack();
- head.bottomAlignContent();
- const hl = head.addStack();
- hl.layoutVertically();
- hero(hl, lead, 40);
- head.addSpacer();
- // The two stats side by side, over the guard meter, in one column as wide as it.
- const col = head.addStack();
- col.layoutVertically();
- col.size = new Size(124, 0);
- pair(col, rest[0], rest[1]);
- col.addSpacer(10);
- const gl = col.addStack();
- text(gl, "QQQ VS SMA", Font.semiboldSystemFont(8), C.text3);
- gl.addSpacer();
- text(gl, pct(d.qqqExt), Font.semiboldSystemFont(10), guardColor(d, p));
- col.addSpacer(3);
- image(col, guardMeter(d, p, 124), 124, 22);
- w.addSpacer();
- image(w, chart(d, p, 306, 180, true), 306, 180);
- w.addSpacer(10);
- image(w, gauge(d, p, price, 306, { prices: true }), 306, gaugeHeight(true));
- }
- function buildError(msg, family) {
- const w = new ListWidget();
- background(w);
- caption(w, "SPY 200SMA");
- w.addSpacer(4);
- text(w, "No reading", Font.boldSystemFont(22), C.white);
- w.addSpacer(4);
- text(w, msg, Font.systemFont(11), C.red, 0.8).lineLimit = 5;
- w.addSpacer();
- text(w, family === "small" ? "Open in Scriptable" : "Open the script in Scriptable: Diagnostics",
- Font.mediumSystemFont(9), C.text3);
- return w;
- }
- async function build(p, family) {
- if (family.startsWith("accessory")) { // lock screen: not supported
- const w = new ListWidget();
- text(w, "SPY 200SMA: home screen only", Font.semiboldSystemFont(12), C.white);
- return w;
- }
- try {
- const d = await load(p);
- const price = d.live ?? d.spy;
- const w = new ListWidget();
- background(w);
- ({ small: buildSmall, medium: buildMedium, large: buildLarge }[family] || buildLarge)(w, d, p, price);
- return w;
- } catch (e) {
- return buildError(String(e.message || e), family);
- }
- }
- // -------------------------------------------------------------------- menu
- async function pick(title, message, options) {
- const a = new Alert();
- a.title = title;
- a.message = message;
- options.forEach(o => a.addAction(o));
- a.addCancelAction("Back");
- return a.presentSheet();
- }
- async function editLines(saved) {
- const s = settings(saved), keys = ["entry", "exit", "g1", "g2"];
- const a = new Alert();
- a.title = "Lines and guards";
- a.message = "Percent. The published rule is BUY +4, SELL −3, GUARD1 30, GUARD2 40.";
- ["BUY: % above the SMA", "SELL: % below the SMA", "GUARD1: QQQ % over its SMA", "GUARD2: QQQ % over its SMA"]
- .forEach((name, i) => a.addTextField(name, String(s[keys[i]])).setDecimalPadKeyboard());
- a.addAction("Save");
- a.addDestructiveAction("Restore the published rule");
- a.addCancelAction("Cancel");
- const i = await a.presentAlert();
- if (i === 1) return { ...saved, ...PUBLISHED };
- if (i !== 0) return saved;
- const next = { ...saved };
- keys.forEach((k, n) => {
- const v = parseFloat(a.textFieldValue(n).replace(",", "."));
- if (isFinite(v) && v > 0) next[k] = v;
- });
- return next;
- }
- async function diagnostics(p) {
- let body;
- try {
- const d = await load(p);
- body = [
- `${d.bars} daily closes kept, ${d.first} to ${d.date}`,
- d.stale ? `STALE, fetch failed: ${d.error}` : `Fetched ${new Date(d.fetched).toLocaleString()}`,
- `SPY close ${px(d.spy)}${d.live != null ? `, live ${px(d.live)}` : ""}`,
- `Holding ${d.settled ? "established" : "UNCERTAIN: the last signal is older than the history"}`,
- `200SMA ${px(d.sma)}, SPY ${pct(d.spy / d.sma - 1, 2)}`,
- `BUY line ${px(d.buyLine)}, SELL line ${px(d.sellLine)}`,
- `QQQ over its SMA ${pct(d.qqqExt, 2)}`,
- `State ${d.state}${d.locked ? " (GUARD2 locked)" : ""} since ${d.since}`,
- ].join("\n");
- } catch (e) {
- body = `FAILED: ${e.message || e}`;
- }
- const a = new Alert();
- a.title = "Diagnostics";
- a.message = body;
- a.addAction("Copy");
- a.addCancelAction("Close");
- if ((await a.presentAlert()) === 0) Pasteboard.copy(body);
- }
- async function menu() {
- const store = files();
- const sizes = [["Small", "small", "presentSmall"], ["Medium", "medium", "presentMedium"],
- ["Large", "large", "presentLarge"]];
- for (;;) {
- const saved = store.read(SETTINGS_FILE) || {};
- const s = settings(saved);
- const rule = ["entry", "exit", "g1", "g2"].every(k => s[k] === PUBLISHED[k])
- ? "the published rule" : `+${s.entry}/−${s.exit}, guards ${s.g1}/${s.g2}`;
- const i = await pick("SPY 200SMA", `Lines: ${rule}`, [
- "Preview", `Chart: ${s.range.toUpperCase()}`, "Lines and guards", "Diagnostics",
- ]);
- if (i < 0) return;
- if (i === 0) {
- const j = await pick("Preview", "", sizes.map(x => x[0]));
- if (j >= 0) {
- const w = await build(s, sizes[j][1]);
- await w[sizes[j][2]]();
- }
- }
- if (i === 1) {
- const keys = Object.keys(RANGES);
- const j = await pick("Chart", "How much history the charts show.", keys.map(k => k.toUpperCase()));
- if (j >= 0) store.write(SETTINGS_FILE, { ...saved, range: keys[j] });
- }
- if (i === 2) store.write(SETTINGS_FILE, await editLines(saved));
- if (i === 3) await diagnostics(s);
- }
- }
- async function main() {
- if (config.runsInApp && !config.runsInWidget) {
- await menu();
- } else {
- const w = await build(settings(files().read(SETTINGS_FILE) || {}), config.widgetFamily || "large");
- w.refreshAfterDate = new Date(Date.now() + 30 * 60 * 1000);
- Script.setWidget(w);
- }
- Script.complete();
- }
- if (typeof __SPY200SMA_TEST__ === "object") {
- Object.assign(__SPY200SMA_TEST__, { settings, sma, replay, merge, rowsOf, compute, nextTrigger, checkDaily, chartURL, fetchBars, load });
- } else {
- await main();
- }
Advertisement
Add Comment
Please, Sign In to add comment