Guest User

Widget TQQQ/QQQ SPY 200SMA 4/3%

a guest
Oct 21st, 2026
462
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. // Variables used by Scriptable.
  2. // These must be at the very top of the file. Do not edit.
  3. // icon-color: deep-purple; icon-glyph: chart-line;
  4.  
  5. // SPY 200SMA strategy for TQQQ
  6. //   BUY     SPY closes above its 200SMA +4%          -> TQQQ
  7. //   SELL    SPY closes below its 200SMA -3%          -> QQQ
  8. //   GUARD1  QQQ closes 30%+ above its own 200SMA     -> warning only
  9. //   GUARD2  QQQ closes 40%+ above its own 200SMA     -> cash; TQQQ again on
  10. //           the first BUY after a SELL
  11. // Signals are read on daily closes; an open session is shown but never acts.
  12.  
  13. const DEFAULTS = { entry: 4, exit: 3, g1: 30, g2: 40, range: "1y" };
  14. const PUBLISHED = { entry: 4, exit: 3, g1: 30, g2: 40 };
  15. const RANGES = { "6m": 126, "1y": 252, "2y": 504 };
  16. const SMA_LEN = 200;
  17. const SETTINGS_FILE = "spy200sma-settings.json";
  18. // History is downloaded once and kept on the phone; each refresh then fetches
  19. // only the last days, which also carry the live price.
  20. const CACHE_FILE = "spy200sma-history.json";
  21. // What you hold depends on the last signal. The first download is 3 years and is
  22. // widened only while that signal is out of reach (no state has lasted longer
  23. // than 3.6 years since 1999).
  24. const BACKFILL_YEARS = [3, 6, 30];
  25. const RECENT_DAYS = 10;
  26. const YEAR = 365.25 * 864e5;
  27. const HOLDING = { LEVERED: "TQQQ", OUT: "QQQ", CASH: "CASH" };
  28.  
  29. // ------------------------------------------------------------------ logic
  30.  
  31. function settings(saved = {}) {
  32.   const s = { ...DEFAULTS, ...saved };
  33.   if (!(s.range in RANGES)) s.range = DEFAULTS.range;
  34.   return { ...s, days: RANGES[s.range] };
  35. }
  36.  
  37. function sma(xs, len) {
  38.   const out = new Array(xs.length).fill(NaN);
  39.   let sum = 0;
  40.   for (let i = 0; i < xs.length; i++) {
  41.     sum += xs[i];
  42.     if (i >= len) sum -= xs[i - len];
  43.     if (i >= len - 1) out[i] = sum / len;
  44.   }
  45.   return out;
  46. }
  47.  
  48. // The rule as a state machine, replayed from the first bar, so the state never
  49. // depends on when the widget last ran. GUARD2 locks out TQQQ until a SELL.
  50. function replay(spy, spySma, qqqExt, p) {
  51.   const entry = p.entry / 100, exit = p.exit / 100, g2line = p.g2 / 100;
  52.   let state = "OUT", locked = false;
  53.   const states = new Array(spy.length), locks = new Array(spy.length);
  54.   for (let i = 0; i < spy.length; i++) {
  55.     if (!isNaN(spySma[i])) {
  56.       const buy = spy[i] > spySma[i] * (1 + entry);
  57.       const sell = spy[i] < spySma[i] * (1 - exit);
  58.       const g2 = qqqExt[i] >= g2line;
  59.       if (sell) locked = false;
  60.       if (state === "LEVERED") {
  61.         if (sell) state = "OUT";
  62.         else if (g2) { state = "CASH"; locked = true; }
  63.       } else if (state === "CASH") {
  64.         if (buy && !locked && !g2) state = "LEVERED";
  65.       } else if (buy && !g2) {
  66.         state = "LEVERED";
  67.       }
  68.     }
  69.     states[i] = state;
  70.     locks[i] = locked;
  71.   }
  72.   return { states, locks };
  73. }
  74.  
  75. // A 200-bar average is a 200-DAY average only if the bars are days. Yahoo can
  76. // return monthly bars without saying so; refuse them rather than draw a signal.
  77. function checkDaily(bars, ticker) {
  78.   const t = bars.slice(-61).map(b => Date.parse(b.date) / 864e5);
  79.   const gaps = t.slice(1).map((x, i) => x - t[i]).sort((a, b) => a - b);
  80.   const median = gaps[Math.floor(gaps.length / 2)];
  81.   // Daily bars across a holiday can sit 2-3 days apart; weekly ones sit 7.
  82.   if (median > 4) throw new Error(`${ticker}: bars are ${median} days apart, not daily`);
  83. }
  84.  
  85. // Stored [date, close] pairs with new bars merged in; a date already stored is
  86. // overwritten, so a revised close replaces the old one.
  87. function merge(pairs, bars) {
  88.   const m = new Map(pairs);
  89.   bars.forEach(b => m.set(b.date, b.close));
  90.   return [...m].sort((a, b) => (a[0] < b[0] ? -1 : 1));
  91. }
  92.  
  93. function rowsOf(h) {
  94.   const q = new Map(h.qqq);
  95.   return h.spy.filter(([date]) => q.has(date)).map(([date, spy]) => ({ date, spy, qqq: q.get(date) }));
  96. }
  97.  
  98. function compute(rows, p) {
  99.   const spy = rows.map(r => r.spy), qqq = rows.map(r => r.qqq);
  100.   const spySma = sma(spy, SMA_LEN), qqqSma = sma(qqq, SMA_LEN);
  101.   const qqqExt = qqq.map((x, i) => x / qqqSma[i] - 1);
  102.   const { states, locks } = replay(spy, spySma, qqqExt, p);
  103.   const n = rows.length - 1;
  104.   let since = n;
  105.   while (since > 0 && states[since - 1] === states[n]) since--;
  106.   // The replay starts blind. After a SELL day and then a BUY day every possible
  107.   // past agrees, GUARD2 cash included, so the state and its start are known only
  108.   // if that BUY came no later than the start.
  109.   const isSell = i => spy[i] < spySma[i] * (1 - p.exit / 100);
  110.   const isBuy = i => spy[i] > spySma[i] * (1 + p.entry / 100) && qqqExt[i] < p.g2 / 100;
  111.   let first = 0;
  112.   while (first <= n && !isSell(first)) first++;
  113.   while (first <= n && !isBuy(first)) first++;
  114.   const from = Math.max(0, rows.length - RANGES["2y"]);
  115.   return {
  116.     date: rows[n].date, first: rows[0].date, bars: rows.length,
  117.     spy: spy[n], sma: spySma[n], qqqExt: qqqExt[n],
  118.     buyLine: spySma[n] * (1 + p.entry / 100), sellLine: spySma[n] * (1 - p.exit / 100),
  119.     state: states[n], locked: locks[n], since: rows[since].date, settled: first <= since,
  120.     history: rows.slice(from).map((r, k) => ({ spy: spy[from + k], sma: spySma[from + k], state: states[from + k] })),
  121.   };
  122. }
  123.  
  124. // The opposite signal: the line that ends what you hold. SELL while in TQQQ,
  125. // BUY while in QQQ or unlocked cash, and the unlocking SELL after GUARD2.
  126. function nextTrigger(d, p, price) {
  127.   const sell = { name: "SELL", line: d.sellLine, gap: d.sellLine / price - 1 };
  128.   const buy = { name: "BUY", line: d.buyLine, gap: d.buyLine / price - 1 };
  129.   if (d.state === "LEVERED") return sell;
  130.   if (d.state === "CASH" && d.locked) return { ...sell, unlock: true };
  131.   return buy;
  132. }
  133.  
  134. // Fixed scale for the gauges, so SELL, SMA and BUY always sit in the same place.
  135. const gaugeScale = p => ({ lo: -(p.exit + 9) / 100, hi: (p.entry + 10) / 100 });
  136.  
  137. // ------------------------------------------------------------------- data
  138.  
  139. function chartURL(host, ticker, from, now = Date.now()) {
  140.   // period1/period2, never range=max: for range=max Yahoo returns monthly bars.
  141.   return `https://${host}.finance.yahoo.com/v8/finance/chart/${ticker}` +
  142.     `?period1=${Math.floor(from / 1000)}&period2=${Math.floor(now / 1000) + 86400}&interval=1d&includePrePost=false`;
  143. }
  144.  
  145. async function fetchBars(ticker, from) {
  146.   let lastErr;
  147.   for (const host of ["query1", "query2"]) {
  148.     try {
  149.       const req = new Request(chartURL(host, ticker, from));
  150.       req.headers = { "User-Agent": "Mozilla/5.0" };
  151.       req.timeoutInterval = 20;
  152.       const r = (await req.loadJSON()).chart.result[0];
  153.       const close = r.indicators.quote[0].close;
  154.       const bars = [];
  155.       r.timestamp.forEach((t, i) => {
  156.         if (close[i] != null) bars.push({ date: new Date(t * 1000).toISOString().slice(0, 10), close: close[i] });
  157.       });
  158.       checkDaily(bars, ticker);
  159.       return bars;
  160.     } catch (e) {
  161.       lastErr = e;
  162.     }
  163.   }
  164.   throw lastErr;
  165. }
  166.  
  167. function nowET() {
  168.   const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
  169.     timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit",
  170.     hour: "2-digit", minute: "2-digit", hour12: false,
  171.   }).formatToParts(new Date()).map(x => [x.type, x.value]));
  172.   const hm = (parseInt(parts.hour, 10) % 24) * 60 + parseInt(parts.minute, 10);
  173.   return { date: `${parts.year}-${parts.month}-${parts.day}`, closed: hm >= 16 * 60 + 5 };
  174. }
  175.  
  176. function files() {
  177.   const fm = FileManager.local();
  178.   const at = name => fm.joinPath(fm.documentsDirectory(), name);
  179.   return {
  180.     read(name) {
  181.       try { return fm.fileExists(at(name)) ? JSON.parse(fm.readString(at(name))) : null; } catch (e) { return null; }
  182.     },
  183.     write(name, value) { fm.writeString(at(name), JSON.stringify(value)); },
  184.   };
  185. }
  186.  
  187. async function load(p) {
  188.   const store = files(), et = nowET();
  189.   const h = store.read(CACHE_FILE) || { years: 0, spy: [], qqq: [] };
  190.   // Only completed sessions are kept: the rule reads closes. Today's bar while
  191.   // the market is open is the live price, shown but never stored.
  192.   const closed = b => b.date < et.date || et.closed;
  193.   let live = null, error = null;
  194.   const get = async from => {
  195.     const [s, q] = await Promise.all([fetchBars("SPY", from), fetchBars("QQQ", from)]);
  196.     h.spy = merge(h.spy, s.filter(closed));
  197.     h.qqq = merge(h.qqq, q.filter(closed));
  198.     const open = s.find(b => !closed(b));
  199.     if (open) live = open.close;
  200.     h.fetched = Date.now();
  201.   };
  202.   try {
  203.     if (!h.years) {
  204.       await get(Date.now() - BACKFILL_YEARS[0] * YEAR);
  205.       h.years = BACKFILL_YEARS[0];
  206.     }
  207.     // Back past the last stored close too, so a phone that was off leaves no gap.
  208.     const last = h.spy.length ? Date.parse(h.spy[h.spy.length - 1][0]) : Date.now();
  209.     await get(Math.min(Date.now() - RECENT_DAYS * 864e5, last - 5 * 864e5));
  210.   } catch (e) {
  211.     if (!h.spy.length) throw e;
  212.     error = String(e.message || e);
  213.   }
  214.   const evaluate = () => {
  215.     const rows = rowsOf(h);
  216.     if (rows.length < SMA_LEN + 60) throw new Error(`only ${rows.length} daily closes`);
  217.     return compute(rows, p);
  218.   };
  219.   let d = evaluate();
  220.   while (!d.settled && !error && BACKFILL_YEARS.some(y => y > h.years)) {
  221.     const wider = BACKFILL_YEARS.find(y => y > h.years);
  222.     try {
  223.       await get(Date.now() - wider * YEAR);
  224.       h.years = wider;
  225.     } catch (e) {
  226.       error = String(e.message || e);
  227.     }
  228.     d = evaluate();
  229.   }
  230.   store.write(CACHE_FILE, h);
  231.   if (!(Math.abs(d.spy / d.sma - 1) < 0.6)) throw new Error(`SPY ${d.spy} vs 200SMA ${d.sma.toFixed(2)} is implausible`);
  232.   return { ...d, live, fetched: h.fetched, stale: !!error, error };
  233. }
  234.  
  235. // ----------------------------------------------------------------- drawing
  236. //
  237. // Colours sampled from Apple's Stocks widget on a dark home screen. Always
  238. // dark: drawn images cannot follow light/dark mode.
  239.  
  240. const hex = (h, a = 1) => new Color(h, a);
  241. const C = {
  242.   white: hex("#ffffff"), text2: hex("#979799"), text3: hex("#979799", 0.8),
  243.   green: hex("#41be5f"), red: hex("#e4433b"), amber: hex("#f5a623"),
  244.   magenta: hex("#ff4fd8"), purple: hex("#bf5af2"),
  245. };
  246. const ACCENT = { LEVERED: C.purple, OUT: C.amber, CASH: C.text2 };
  247.  
  248. const pct = (x, d = 1) => `${x >= 0 ? "+" : "−"}${Math.abs(x * 100).toFixed(d)}%`;
  249. const px = x => x.toFixed(2);
  250. const parseISO = iso => { const [y, m, d] = iso.split("-").map(Number); return new Date(y, m - 1, d); };
  251. const daysBetween = (a, b) => Math.round((parseISO(b) - parseISO(a)) / 864e5);
  252. const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
  253. // Height a line of drawn text needs: iOS puts the baseline ~0.95em down.
  254. const lineH = size => Math.ceil(size * 1.25) + 1;
  255. const gaugeHeight = prices => 16 + lineH(8) + (prices ? lineH(8) : 0);
  256.  
  257. function canvas(w, h) {
  258.   const ctx = new DrawContext();
  259.   ctx.size = new Size(w, h);
  260.   ctx.opaque = false;
  261.   ctx.respectScreenScale = true;
  262.   return ctx;
  263. }
  264.  
  265. function stroke(ctx, pts, color, width) {
  266.   const path = new Path();
  267.   path.addLines(pts);
  268.   ctx.addPath(path);
  269.   ctx.setStrokeColor(color);
  270.   ctx.setLineWidth(width);
  271.   ctx.strokePath();
  272. }
  273.  
  274. function fill(ctx, pts, color) {
  275.   const path = new Path();
  276.   path.addLines(pts);
  277.   path.closeSubpath();
  278.   ctx.addPath(path);
  279.   ctx.setFillColor(color);
  280.   ctx.fillPath();
  281. }
  282.  
  283. function rounded(ctx, x, y, w, h, color) {
  284.   const path = new Path();
  285.   path.addRoundedRect(new Rect(x, y, Math.max(w, 0), h), h / 2, h / 2);
  286.   ctx.addPath(path);
  287.   ctx.setFillColor(color);
  288.   ctx.fillPath();
  289. }
  290.  
  291. function label(ctx, s, x, y, w, size, color, align = "center") {
  292.   ctx.setFont(Font.semiboldSystemFont(size));
  293.   ctx.setTextColor(color);
  294.   if (align === "left") ctx.setTextAlignedLeft();
  295.   else ctx.setTextAlignedCenter();
  296.   ctx.drawTextInRect(s, new Rect(align === "left" ? x : x - w / 2, y, w, size * 1.4));
  297. }
  298.  
  299. function dot(ctx, x, y, r) {
  300.   ctx.setFillColor(hex("#000000", 0.6));
  301.   ctx.fillEllipse(new Rect(x - r - 1.5, y - r - 1.5, 2 * r + 3, 2 * r + 3));
  302.   ctx.setFillColor(C.white);
  303.   ctx.fillEllipse(new Rect(x - r, y - r, 2 * r, 2 * r));
  304. }
  305.  
  306. // SPY against its SMA: red below SELL, green above BUY, magenta tick at the SMA.
  307. function gauge(d, p, price, w, { prices = false } = {}) {
  308.   const ext = price / d.sma - 1, { lo, hi } = gaugeScale(p);
  309.   const pad = 7, ty = 6, th = 6, ctx = canvas(w, gaugeHeight(prices));
  310.   const X = e => pad + ((clamp(e, lo, hi) - lo) / (hi - lo)) * (w - 2 * pad);
  311.   const xs = X(-p.exit / 100), xb = X(p.entry / 100), xm = X(0);
  312.   const zone = c => hex(c, 0.9);
  313.   rounded(ctx, pad - th / 2, ty, w - 2 * pad + th, th, hex("#ffffff", 0.16));
  314.   rounded(ctx, pad - th / 2, ty, xs - pad + th / 2, th, zone("#e4433b"));
  315.   rounded(ctx, xb, ty, w - pad + th / 2 - xb, th, zone("#41be5f"));
  316.   ctx.setFillColor(C.magenta);
  317.   ctx.fillRect(new Rect(xm - 1, ty - 3, 2, th + 6));
  318.   dot(ctx, X(ext), ty + th / 2, 5);
  319.   const cy = ty + th / 2;   // off the scale: an arrow at that end
  320.   if (ext > hi) fill(ctx, [new Point(w - 6, cy - 4), new Point(w - 1, cy), new Point(w - 6, cy + 4)], C.white);
  321.   if (ext < lo) fill(ctx, [new Point(6, cy - 4), new Point(1, cy), new Point(6, cy + 4)], C.white);
  322.   const dim = C.text3, y2 = 16 + lineH(8);
  323.   label(ctx, "SELL", xs, 16, 40, 8, C.red);
  324.   label(ctx, "BUY", xb, 16, 40, 8, C.green);
  325.   if (prices) {
  326.     label(ctx, "SMA", xm, 16, 40, 8, C.magenta);
  327.     label(ctx, px(d.sellLine), xs, y2, 50, 8, dim);
  328.     label(ctx, px(d.sma), xm, y2, 50, 8, dim);
  329.     label(ctx, px(d.buyLine), xb, y2, 50, 8, dim);
  330.   }
  331.   return ctx.getImage();
  332. }
  333.  
  334. // SPY, its SMA and the two lines, with a ribbon underneath of what the rule
  335. // held: purple TQQQ, amber QQQ, grey cash.
  336. function chart(d, p, w, h, names = false) {
  337.   const hist = d.history.slice(-p.days).filter(b => !isNaN(b.sma));
  338.   const end = hist[hist.length - 1];
  339.   if (end && d.live != null) hist.push({ ...end, spy: d.live });   // the line ends at the live price
  340.   const ctx = canvas(w, h);
  341.   if (hist.length < 2) return ctx.getImage();
  342.   const up = 1 + p.entry / 100, dn = 1 - p.exit / 100, ph = h - 8;
  343.   const vals = hist.flatMap(b => [b.spy, b.sma * up, b.sma * dn]);
  344.   const lo = Math.min(...vals), hi = Math.max(...vals), padY = (hi - lo) * 0.06;
  345.   const X = i => 1 + (i / (hist.length - 1)) * (w - 8);
  346.   const Y = v => ph - 2 - ((v - lo + padY) / (hi - lo + 2 * padY)) * (ph - 8);
  347.   const line = f => hist.map((b, i) => new Point(X(i), Y(f(b))));
  348.   fill(ctx, [...line(b => b.sma * up), ...line(b => b.sma * dn).reverse()], hex("#ffffff", 0.06));
  349.   fill(ctx, [new Point(X(0), ph), ...line(b => b.spy), new Point(X(hist.length - 1), ph)], hex("#ffffff", 0.05));
  350.   stroke(ctx, line(b => b.sma * up), hex("#41be5f", 0.85), 1);
  351.   stroke(ctx, line(b => b.sma * dn), hex("#e4433b", 0.85), 1);
  352.   stroke(ctx, line(b => b.sma), hex("#ff4fd8", 0.95), 1.25);
  353.   stroke(ctx, line(b => b.spy), C.white, 1.5);
  354.   const lx = X(hist.length - 1), ly = Y(hist[hist.length - 1].spy);
  355.   ctx.setFillColor(hex("#ffffff", 0.25));
  356.   ctx.fillEllipse(new Rect(lx - 6, ly - 6, 12, 12));
  357.   ctx.setFillColor(C.white);
  358.   ctx.fillEllipse(new Rect(lx - 3, ly - 3, 6, 6));
  359.   const col = { LEVERED: "#bf5af2", OUT: "#f5a623", CASH: "#8e8e93" };
  360.   let start = 0;
  361.   hist.forEach((b, i) => {
  362.     if (i < hist.length - 1 && hist[i + 1].state === b.state) return;
  363.     rounded(ctx, X(start), h - 3, Math.max(3, X(i) - X(start) - 1.5), 3, hex(col[b.state], 0.95));
  364.     start = i + 1;
  365.   });
  366.   if (names) {
  367.     const f = hist[0];
  368.     label(ctx, "BUY", 2, clamp(Y(f.sma * up) - 12, 0, ph - lineH(8)), 40, 8, C.green, "left");
  369.     label(ctx, "SELL", 2, clamp(Y(f.sma * dn) + 2, 0, ph - lineH(8)), 40, 8, C.red, "left");
  370.   }
  371.   return ctx.getImage();
  372. }
  373.  
  374. // QQQ over its own SMA against the two guard lines.
  375. function guardMeter(d, p, w) {
  376.   const top = p.g2 + 10, ctx = canvas(w, 22), e = d.qqqExt * 100;
  377.   const X = v => (clamp(v, 0, top) / top) * w;
  378.   rounded(ctx, 0, 4, w, 4, hex("#ffffff", 0.16));
  379.   if (e > 0) rounded(ctx, 0, 4, Math.max(4, X(e)), 4, guardColor(d, p));
  380.   for (const [g, c] of [[p.g1, C.amber], [p.g2, C.red]]) {
  381.     ctx.setFillColor(c);
  382.     ctx.fillRect(new Rect(X(g) - 1, 1, 2, 10));
  383.     label(ctx, `${g}%`, X(g), 12, 30, 8, C.text3);
  384.   }
  385.   return ctx.getImage();
  386. }
  387.  
  388. // ------------------------------------------------------------------ layout
  389.  
  390. function text(stack, s, font, color, scale = 0.6) {
  391.   const t = stack.addText(s);
  392.   t.font = font;
  393.   t.textColor = color;
  394.   t.lineLimit = 1;
  395.   t.minimumScaleFactor = scale;
  396.   return t;
  397. }
  398.  
  399. function stat(stack, f, size = 13) {
  400.   const s = stack.addStack();
  401.   s.layoutVertically();
  402.   text(s, f.caption.toUpperCase(), Font.semiboldSystemFont(8), C.text3);
  403.   s.addSpacer(1);
  404.   text(s, f.value, Font.semiboldSystemFont(size), f.color);
  405.   return s;
  406. }
  407.  
  408. function image(stack, img, w, h) {
  409.   stack.addImage(img).imageSize = new Size(w, h);
  410. }
  411.  
  412. const caption = (stack, s) => text(stack, s, Font.boldSystemFont(10), C.text3);
  413. const guardColor = (d, p) => (d.qqqExt * 100 >= p.g2 ? C.red : d.qqqExt * 100 >= p.g1 ? C.amber : C.white);
  414. const side = (d, price) => `${price >= d.sma ? "over" : "under"} 200SMA`;
  415.  
  416. // Shown only when Yahoo failed and the reading is an old one.
  417. function stamp(d) {
  418.   return `Stale since ${new Date(d.fetched).toLocaleString("en-US",
  419.     { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
  420. }
  421.  
  422. // The headline is SPY over or under its SMA; what you hold and the distance to
  423. // the opposite signal sit underneath as stats.
  424. function facts(d, p, price) {
  425.   const t = nextTrigger(d, p, price), ext = price / d.sma - 1;
  426.   const extColor = ext >= 0 ? C.green : C.red;
  427.   const days = daysBetween(d.since, d.date);
  428.   const holding = { caption: `Held ${days}d`, value: HOLDING[d.state], color: ACCENT[d.state] };
  429.   const spy = { value: pct(ext), color: extColor, sub: side(d, price) };
  430.   const signal = { caption: t.unlock ? "to unlock" : `to ${t.name}`, value: pct(t.gap), color: C.white };
  431.   return { lead: spy, rest: [holding, signal] };
  432. }
  433.  
  434. function hero(stack, f, size) {
  435.   text(stack, f.value, Font.boldSystemFont(size), f.color, 0.5);
  436.   text(stack, f.sub, Font.semiboldSystemFont(11), C.text2);
  437. }
  438.  
  439. function pair(stack, a, b) {
  440.   const r = stack.addStack();
  441.   r.layoutHorizontally();
  442.   r.bottomAlignContent();
  443.   stat(r, a);
  444.   r.addSpacer(6);
  445.   r.addSpacer();
  446.   stat(r, b);
  447. }
  448.  
  449. function background(w) {
  450.   const g = new LinearGradient();
  451.   g.colors = [new Color("#1f1f1f"), new Color("#0d0d0d")];
  452.   g.locations = [0, 1];
  453.   g.startPoint = new Point(0.5, 0);
  454.   g.endPoint = new Point(0.5, 1);
  455.   w.backgroundGradient = g;
  456. }
  457.  
  458. function buildSmall(w, d, p, price) {
  459.   const { lead, rest } = facts(d, p, price);
  460.   w.setPadding(14, 14, 12, 14);
  461.   const top = w.addStack();
  462.   caption(top, "SPY 200SMA");
  463.   if (d.stale) {
  464.     top.addSpacer();
  465.     text(top, "Stale", Font.boldSystemFont(10), C.amber);
  466.   }
  467.   w.addSpacer(3);
  468.   hero(w, lead, 32);
  469.   w.addSpacer();
  470.   pair(w, rest[0], rest[1]);
  471.   w.addSpacer(6);
  472.   image(w, gauge(d, p, price, 130), 130, gaugeHeight(false));
  473. }
  474.  
  475. function buildMedium(w, d, p, price) {
  476.   const { lead, rest } = facts(d, p, price);
  477.   w.setPadding(14, 14, 12, 14);
  478.   const row = w.addStack();
  479.   const left = row.addStack();
  480.   left.layoutVertically();
  481.   left.size = new Size(124, 132);
  482.   caption(left, "SPY 200SMA");
  483.   left.addSpacer(3);
  484.   hero(left, lead, 30);
  485.   left.addSpacer();
  486.   pair(left, rest[0], rest[1]);
  487.   if (d.stale) {
  488.     left.addSpacer(3);
  489.     text(left, stamp(d), Font.mediumSystemFont(9), C.amber);
  490.   }
  491.   row.addSpacer(10);
  492.   const right = row.addStack();
  493.   right.layoutVertically();
  494.   image(right, chart(d, p, 176, 92), 176, 92);
  495.   right.addSpacer(8);
  496.   image(right, gauge(d, p, price, 176), 176, gaugeHeight(false));
  497. }
  498.  
  499. function buildLarge(w, d, p, price) {
  500.   const { lead, rest } = facts(d, p, price);
  501.   w.setPadding(16, 16, 14, 16);
  502.   const top = w.addStack();
  503.   top.centerAlignContent();
  504.   for (const s of ["SPY 200SMA", `+${p.entry}/−${p.exit}`, p.range.toUpperCase()]) {
  505.     caption(top, s);
  506.     top.addSpacer(8);
  507.   }
  508.   if (d.stale) {
  509.     top.addSpacer();
  510.     text(top, stamp(d), Font.mediumSystemFont(10), C.amber);
  511.   }
  512.   w.addSpacer(4);
  513.   const head = w.addStack();
  514.   head.bottomAlignContent();
  515.   const hl = head.addStack();
  516.   hl.layoutVertically();
  517.   hero(hl, lead, 40);
  518.   head.addSpacer();
  519.   // The two stats side by side, over the guard meter, in one column as wide as it.
  520.   const col = head.addStack();
  521.   col.layoutVertically();
  522.   col.size = new Size(124, 0);
  523.   pair(col, rest[0], rest[1]);
  524.   col.addSpacer(10);
  525.   const gl = col.addStack();
  526.   text(gl, "QQQ VS SMA", Font.semiboldSystemFont(8), C.text3);
  527.   gl.addSpacer();
  528.   text(gl, pct(d.qqqExt), Font.semiboldSystemFont(10), guardColor(d, p));
  529.   col.addSpacer(3);
  530.   image(col, guardMeter(d, p, 124), 124, 22);
  531.   w.addSpacer();
  532.   image(w, chart(d, p, 306, 180, true), 306, 180);
  533.   w.addSpacer(10);
  534.   image(w, gauge(d, p, price, 306, { prices: true }), 306, gaugeHeight(true));
  535. }
  536.  
  537. function buildError(msg, family) {
  538.   const w = new ListWidget();
  539.   background(w);
  540.   caption(w, "SPY 200SMA");
  541.   w.addSpacer(4);
  542.   text(w, "No reading", Font.boldSystemFont(22), C.white);
  543.   w.addSpacer(4);
  544.   text(w, msg, Font.systemFont(11), C.red, 0.8).lineLimit = 5;
  545.   w.addSpacer();
  546.   text(w, family === "small" ? "Open in Scriptable" : "Open the script in Scriptable: Diagnostics",
  547.     Font.mediumSystemFont(9), C.text3);
  548.   return w;
  549. }
  550.  
  551. async function build(p, family) {
  552.   if (family.startsWith("accessory")) {   // lock screen: not supported
  553.     const w = new ListWidget();
  554.     text(w, "SPY 200SMA: home screen only", Font.semiboldSystemFont(12), C.white);
  555.     return w;
  556.   }
  557.   try {
  558.     const d = await load(p);
  559.     const price = d.live ?? d.spy;
  560.     const w = new ListWidget();
  561.     background(w);
  562.     ({ small: buildSmall, medium: buildMedium, large: buildLarge }[family] || buildLarge)(w, d, p, price);
  563.     return w;
  564.   } catch (e) {
  565.     return buildError(String(e.message || e), family);
  566.   }
  567. }
  568.  
  569. // -------------------------------------------------------------------- menu
  570.  
  571. async function pick(title, message, options) {
  572.   const a = new Alert();
  573.   a.title = title;
  574.   a.message = message;
  575.   options.forEach(o => a.addAction(o));
  576.   a.addCancelAction("Back");
  577.   return a.presentSheet();
  578. }
  579.  
  580. async function editLines(saved) {
  581.   const s = settings(saved), keys = ["entry", "exit", "g1", "g2"];
  582.   const a = new Alert();
  583.   a.title = "Lines and guards";
  584.   a.message = "Percent. The published rule is BUY +4, SELL −3, GUARD1 30, GUARD2 40.";
  585.   ["BUY: % above the SMA", "SELL: % below the SMA", "GUARD1: QQQ % over its SMA", "GUARD2: QQQ % over its SMA"]
  586.     .forEach((name, i) => a.addTextField(name, String(s[keys[i]])).setDecimalPadKeyboard());
  587.   a.addAction("Save");
  588.   a.addDestructiveAction("Restore the published rule");
  589.   a.addCancelAction("Cancel");
  590.   const i = await a.presentAlert();
  591.   if (i === 1) return { ...saved, ...PUBLISHED };
  592.   if (i !== 0) return saved;
  593.   const next = { ...saved };
  594.   keys.forEach((k, n) => {
  595.     const v = parseFloat(a.textFieldValue(n).replace(",", "."));
  596.     if (isFinite(v) && v > 0) next[k] = v;
  597.   });
  598.   return next;
  599. }
  600.  
  601. async function diagnostics(p) {
  602.   let body;
  603.   try {
  604.     const d = await load(p);
  605.     body = [
  606.       `${d.bars} daily closes kept, ${d.first} to ${d.date}`,
  607.       d.stale ? `STALE, fetch failed: ${d.error}` : `Fetched ${new Date(d.fetched).toLocaleString()}`,
  608.       `SPY close ${px(d.spy)}${d.live != null ? `, live ${px(d.live)}` : ""}`,
  609.       `Holding ${d.settled ? "established" : "UNCERTAIN: the last signal is older than the history"}`,
  610.       `200SMA ${px(d.sma)}, SPY ${pct(d.spy / d.sma - 1, 2)}`,
  611.       `BUY line ${px(d.buyLine)}, SELL line ${px(d.sellLine)}`,
  612.       `QQQ over its SMA ${pct(d.qqqExt, 2)}`,
  613.       `State ${d.state}${d.locked ? " (GUARD2 locked)" : ""} since ${d.since}`,
  614.     ].join("\n");
  615.   } catch (e) {
  616.     body = `FAILED: ${e.message || e}`;
  617.   }
  618.   const a = new Alert();
  619.   a.title = "Diagnostics";
  620.   a.message = body;
  621.   a.addAction("Copy");
  622.   a.addCancelAction("Close");
  623.   if ((await a.presentAlert()) === 0) Pasteboard.copy(body);
  624. }
  625.  
  626. async function menu() {
  627.   const store = files();
  628.   const sizes = [["Small", "small", "presentSmall"], ["Medium", "medium", "presentMedium"],
  629.     ["Large", "large", "presentLarge"]];
  630.   for (;;) {
  631.     const saved = store.read(SETTINGS_FILE) || {};
  632.     const s = settings(saved);
  633.     const rule = ["entry", "exit", "g1", "g2"].every(k => s[k] === PUBLISHED[k])
  634.       ? "the published rule" : `+${s.entry}/−${s.exit}, guards ${s.g1}/${s.g2}`;
  635.     const i = await pick("SPY 200SMA", `Lines: ${rule}`, [
  636.       "Preview", `Chart: ${s.range.toUpperCase()}`, "Lines and guards", "Diagnostics",
  637.     ]);
  638.     if (i < 0) return;
  639.     if (i === 0) {
  640.       const j = await pick("Preview", "", sizes.map(x => x[0]));
  641.       if (j >= 0) {
  642.         const w = await build(s, sizes[j][1]);
  643.         await w[sizes[j][2]]();
  644.       }
  645.     }
  646.     if (i === 1) {
  647.       const keys = Object.keys(RANGES);
  648.       const j = await pick("Chart", "How much history the charts show.", keys.map(k => k.toUpperCase()));
  649.       if (j >= 0) store.write(SETTINGS_FILE, { ...saved, range: keys[j] });
  650.     }
  651.     if (i === 2) store.write(SETTINGS_FILE, await editLines(saved));
  652.     if (i === 3) await diagnostics(s);
  653.   }
  654. }
  655.  
  656. async function main() {
  657.   if (config.runsInApp && !config.runsInWidget) {
  658.     await menu();
  659.   } else {
  660.     const w = await build(settings(files().read(SETTINGS_FILE) || {}), config.widgetFamily || "large");
  661.     w.refreshAfterDate = new Date(Date.now() + 30 * 60 * 1000);
  662.     Script.setWidget(w);
  663.   }
  664.   Script.complete();
  665. }
  666.  
  667. if (typeof __SPY200SMA_TEST__ === "object") {
  668.   Object.assign(__SPY200SMA_TEST__, { settings, sma, replay, merge, rowsOf, compute, nextTrigger, checkDaily, chartURL, fetchBars, load });
  669. } else {
  670.   await main();
  671. }
  672.  
Advertisement
Add Comment
Please, Sign In to add comment