Analyzes all 35 Torn City stocks and scores them for buy signals using 4 data-backed indicators: drop from weekly peak (dynamic volatility threshold), position in short-term range, active price rise (m30>h1>h2), and MACD momentum. Backtested on 42 days of hourly data with 88% hit rate. Includes ROI planner, benefit block tracker, swing trade P/L, and Quick Trade bar.
// ==UserScript==
// @name Torn Stock Analyzer
// @namespace https://greasyfork.org
// @version 2.5.1
// @author AeC3
// @description Analyzes all 35 Torn City stocks and scores them for buy signals using 4 data-backed indicators: drop from weekly peak (dynamic volatility threshold), position in short-term range, active price rise (m30>h1>h2), and MACD momentum. Backtested on 42 days of hourly data with 88% hit rate. Includes ROI planner, benefit block tracker, swing trade P/L, and Quick Trade bar.
// @match https://www.torn.com/page.php?sid=stocks*
// @run-at document-end
// @license MIT
// @grant GM_xmlhttpRequest
// @connect tornsy.com
// @connect api.torn.com
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// ==/UserScript==
(function () {
function lsGet(key, fallback) {
try { var v = localStorage.getItem(key); return v !== null ? v : (fallback !== undefined ? fallback : ""); }
catch(e) { return fallback !== undefined ? fallback : ""; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch(e) {}
}
var TORN_API_KEY = lsGet("tsa-torn-apikey", "");
function getTornKey() {
return lsGet("tsa-torn-apikey", "");
}
var KEY_BUILDER_URL = "https://www.torn.com/preferences.php#tab=api?step=addNewKey&user=basic,money,stocks&faction=donations&market=itemmarket&title=TORN%20STOCK%20ANALYZER";
function showKeyOnboarding(contentEl, onSave) {
var isDark = document.getElementById("tsa-overlay") ? document.getElementById("tsa-overlay").classList.contains("tsa-dark") : false;
var bg = isDark ? "#0f0f1a" : "#ffffff";
var bg2 = isDark ? "#1a1a2e" : "#f7f9fc";
var text = isDark ? "#c8c8d8" : "#222";
var muted = isDark ? "#6a6a8a" : "#888";
var border = isDark ? "#2a2a4a" : "#e0e0e0";
contentEl.innerHTML =
"<div style=\"padding:18px;background:" + bg + ";font-family:sans-serif\">" +
"<div style=\"font-size:13px;font-weight:bold;color:" + text + ";margin-bottom:6px\">API Key Required</div>" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:14px;line-height:1.5\">Torn Stock Analyzer needs a Torn API key to load stock and portfolio data. Your key is stored only in your browser and sent exclusively to api.torn.com.</div>" +
"<a href=\"" + KEY_BUILDER_URL + "\" target=\"_blank\" style=\"display:block;text-align:center;padding:10px;border-radius:8px;background:#4a6fa5;color:#fff;font-size:12px;font-weight:bold;text-decoration:none;margin-bottom:14px\">Create custom key (recommended)</a>" +
"<div style=\"font-size:10px;color:" + muted + ";margin-bottom:6px;text-align:center\">— or enter an existing key below —</div>" +
"<div style=\"font-size:10px;color:" + muted + ";margin-bottom:6px\">Limited Access key or custom key</div>" +
"<input id=\"tsa-key-input\" type=\"text\" placeholder=\"Paste API key here\" style=\"width:100%;box-sizing:border-box;padding:8px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:12px;margin-bottom:10px\">" +
"<button id=\"tsa-key-save\" style=\"width:100%;padding:9px;border-radius:7px;border:none;background:#4a6fa5;color:#fff;font-size:13px;font-weight:bold;cursor:pointer\">Save key</button>" +
"</div>";
document.getElementById("tsa-key-save").addEventListener("click", function() {
var val = (document.getElementById("tsa-key-input").value || "").trim();
if (!val) return;
lsSet("tsa-torn-apikey", val);
TORN_API_KEY = val;
if (onSave) onSave();
});
}
// On load: handle PDA key injection, do NOT prompt — onboarding shown inline when needed
if (!TORN_API_KEY || TORN_API_KEY === "###PDA-APIKEY###") {
var pdaKey = "###PDA-APIKEY###";
if (pdaKey.indexOf("PDA-APIKEY") === -1) {
// Running in PDA — key was injected
TORN_API_KEY = pdaKey;
lsSet("tsa-torn-apikey", TORN_API_KEY);
}
}
var roiPlannerActive = false;
var isDesktop = window.innerWidth > 768 || !/Mobi|Android/i.test(navigator.userAgent);
var autoRefreshTimer = null;
var autoRefreshEndTime = null;
var autoRefreshCountdownInterval = null;
var lastLoadPrices = {};
var tsaPinned = (function() { try { return JSON.parse(localStorage.getItem("tsa_pinned") || "[]") || []; } catch(e) { return []; } })();
function getAutoRefreshInterval() {
return parseInt(lsGet("tsa-auto-refresh-interval", "0"), 10);
}
function scheduleAutoRefresh() {
if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
if (autoRefreshCountdownInterval) { clearInterval(autoRefreshCountdownInterval); autoRefreshCountdownInterval = null; }
autoRefreshEndTime = null;
var mins = getAutoRefreshInterval();
if (mins > 0) {
autoRefreshEndTime = Date.now() + mins * 60000;
autoRefreshCountdownInterval = setInterval(updateCountdownLabel, 1000);
autoRefreshTimer = setTimeout(function() {
autoRefreshTimer = null;
if (autoRefreshCountdownInterval) { clearInterval(autoRefreshCountdownInterval); autoRefreshCountdownInterval = null; }
autoRefreshEndTime = null;
loadData();
}, mins * 60000);
}
}
// ── Price Alerts ──────────────────────────────────────────────────────────
var ALERTS_KEY = "tsa_price_alerts";
function loadAlerts() {
try { return JSON.parse(localStorage.getItem(ALERTS_KEY)) || []; } catch(e) { return []; }
}
function saveAlerts(alerts) {
lsSet(ALERTS_KEY, JSON.stringify(alerts));
}
function addAlert(sym, price, dir, repeat) {
var alerts = loadAlerts();
// Remove any existing alert for same sym+dir
alerts = alerts.filter(function(a) { return !(a.sym === sym && a.dir === dir); });
alerts.push({ sym: sym.toUpperCase(), price: price, dir: dir, repeat: !!repeat });
saveAlerts(alerts);
}
function removeAlert(sym, dir) {
var alerts = loadAlerts().filter(function(a) { return !(a.sym === sym && a.dir === dir); });
saveAlerts(alerts);
}
function checkAlerts(raw) {
if (!raw || !raw.length) return;
var alerts = loadAlerts();
if (!alerts.length) return;
var fired = [];
alerts.forEach(function(a) {
var entry = raw.find(function(r) { return r.stock === a.sym; });
if (!entry) return;
var live = parseFloat(entry.price) || 0;
if (live <= 0) return;
var triggered = (a.dir === "above" && live >= a.price) || (a.dir === "below" && live <= a.price);
if (triggered) fired.push({ sym: a.sym, price: a.price, dir: a.dir, live: live });
});
if (!fired.length) return;
// Remove one-shot fired alerts; keep repeat alerts active
var firedKeys = fired.map(function(f) { return f.sym + f.dir; });
saveAlerts(alerts.filter(function(a) { return a.repeat || firedKeys.indexOf(a.sym + a.dir) < 0; }));
// Notify
fired.forEach(function(f) {
var msg = f.sym + " is " + (f.dir === "above" ? "above" : "below") + " $" + f.price.toFixed(2) + " (live: $" + f.live.toFixed(2) + ")";
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
new Notification("Torn Stock Alert", { body: msg, icon: "https://www.torn.com/favicon.ico" });
} else {
showToast("Price Alert: " + msg, "warn");
}
});
}
function requestNotificationPermission() {
if (typeof Notification !== "undefined" && Notification.permission === "default") {
Notification.requestPermission();
}
}
// ─────────────────────────────────────────────────────────────────────────
var lastOwnedMap = null;
var lastRaw = null;
var lastBestRec = null; // Best ROI recommendation from last data load
var STOCK_ID_MAP = {
1:"TSB", 2:"TCI", 3:"SYS", 4:"LAG", 5:"IOU",
6:"GRN", 7:"THS", 8:"YAZ", 9:"TCT", 10:"CNC",
11:"MSG", 12:"TMI", 13:"TCP", 14:"IIL", 15:"FHG",
16:"SYM", 17:"LSC", 18:"PRN", 19:"EWM", 20:"TCM",
21:"ELT", 22:"HRG", 23:"TGP", 24:"MUN", 25:"WSU",
26:"IST", 27:"BAG", 28:"EVL", 29:"MCS", 30:"WLT",
31:"TCC", 32:"ASS", 33:"CBD", 34:"LOS", 35:"PTS"
};
var PASSIVE_STOCKS = ["ELT","IIL","IST","LOS","MSG","SYS","TCP","TCM","TCI","TGP","WSU","WLT","YAZ"];
// Benefit requirement per stock (shares for 1 increment)
var BENEFIT_REQ = {
"TSB":3000000, "TCI":1500000, "SYS":3000000, "LAG":750000, "IOU":3000000,
"GRN":500000, "THS":150000, "YAZ":1000000, "TCT":100000, "CNC":7500000,
"MSG":300000, "TMI":6000000, "TCP":1000000, "IIL":1000000, "FHG":2000000,
"SYM":500000, "LSC":500000, "PRN":1000000, "EWM":1000000, "TCM":1000000,
"ELT":5000000, "HRG":10000000,"TGP":2500000, "MUN":5000000, "WSU":1000000,
"IST":100000, "BAG":3000000, "EVL":100000, "MCS":350000, "WLT":9000000,
"TCC":7500000, "ASS":1000000, "CBD":350000, "LOS":7500000, "PTS":10000000
};
var STOCKS_LIST = ["ass","bag","cbd","cnc","elt","evl","ewm","fhg","grn","hrg",
"iil","iou","ist","lag","los","lsc","mcs","msg","mun","prn",
"pts","sym","sys","tcc","tci","tcm","tcp","tct","tgp","ths",
"tmi","tsb","wlt","wsu","yaz"];
var STYLES = "\n\n #tsa-btn {\n\n position: fixed; bottom: 80px; right: 16px; z-index: 2147483647;\n\n background: #4a6fa5; color: #ffffff; border: none;\n\n border-radius: 50px; padding: 10px 18px; font-size: 13px;\n\n font-family: Arial, sans-serif; cursor: pointer; font-weight: bold;\n\n box-shadow: 0 2px 8px rgba(0,0,0,0.3);\n\n -webkit-tap-highlight-color: transparent;\n\n }\n\n #tsa-btn:hover { background: #3a5f95; }\n\n #tsa-overlay {\n\n position: fixed; bottom: 130px; right: 16px; z-index: 2147483646;\n\n max-height: 75vh; overflow-y: auto;\n\n background: #ffffff; border: 1px solid #ddd; border-radius: 12px;\n\n font-family: Arial, sans-serif; font-size: 12px; color: #222;\n\n box-shadow: 0 4px 20px rgba(0,0,0,0.15); display: none;\n\n }\n\n #tsa-overlay::-webkit-scrollbar { width: 4px; }\n\n #tsa-overlay::-webkit-scrollbar-thumb { background: #ccc; border-radius: 2px; }\n\n .tsa-header {\n\n display: flex; align-items: center; justify-content: space-between;\n\n padding: 12px 14px; border-bottom: 1px solid #eee;\n\n position: sticky; top: 0; background: #ffffff; z-index: 1;\n\n }\n\n .tsa-header-left { display: flex; align-items: center; gap: 8px; }\n\n .tsa-title { font-size: 13px; font-weight: bold; color: #4a6fa5; letter-spacing: 0.05em; }\n\n .tsa-theme-btn {\n\n font-size: 14px; cursor: pointer; background: none; border: none;\n\n padding: 2px 4px; line-height: 1; opacity: 0.7;\n\n }\n\n .tsa-theme-btn:hover { opacity: 1; }\n\n .tsa-close { cursor: pointer; color: #777; font-size: 18px; padding: 0 4px; line-height: 1; }\n\n .tsa-close:hover { color: #333; }\n\n .tsa-stats {\n\n display: grid; grid-template-columns: repeat(3, 1fr);\n\n gap: 8px; padding: 12px 14px; border-bottom: 1px solid #eee;\n\n }\n\n .tsa-stat { background: #f7f9fc; border-radius: 8px; padding: 8px; text-align: center; border: 1px solid #e8edf5; }\n\n .tsa-stat-label { font-size: 10px; color: #666; margin-bottom: 4px; }\n\n .tsa-stat-value { font-size: 16px; font-weight: bold; color: #222; }\n\n .tsa-stat-value.green { color: #1a8a45; }\n\n .tsa-stat-value.red { color: #cc2222; }\n\n .tsa-section { padding: 10px 14px 6px; }\n\n .tsa-section-title { font-size: 10px; letter-spacing: 0.12em; color: #777; text-transform: uppercase; margin-bottom: 8px; font-weight: bold; }\n\n .tsa-row {\n\n display: flex; align-items: center; justify-content: space-between;\n\n padding: 8px 10px; border-radius: 8px; margin-bottom: 5px; cursor: pointer;\n\n }\n\n .tsa-row.buy { background: #edfaf3; border: 1px solid #a8e6c0; }\n\n .tsa-row.sell { background: #fff0f0; border: 1px solid #ffb3b3; }\n\n .tsa-row.hold { background: #f0f4ff; border: 1px solid #c0d0ff; }\n\n .tsa-row.buy:active { background: #d0f5e3; }\n\n .tsa-row.sell:active { background: #ffd8d8; }\n\n .tsa-row.hold:active { background: #dce6ff; }\n\n .tsa-row-left { display: flex; flex-direction: column; gap: 2px; }\n\n .tsa-symbol { font-size: 13px; font-weight: bold; }\n\n .tsa-symbol.buy { color: #1a8a45; }\n\n .tsa-symbol.sell { color: #cc2222; }\n\n .tsa-symbol.hold { color: #4a6fa5; }\n\n .tsa-detail { font-size: 10px; color: #666; }\n\n .tsa-row-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }\n\n .tsa-score { font-size: 14px; font-weight: bold; }\n\n .tsa-score.buy { color: #1a8a45; }\n\n .tsa-score.sell { color: #cc2222; }\n\n .tsa-score.hold { color: #4a6fa5; }\n\n .tsa-badge { font-size: 9px; padding: 2px 6px; border-radius: 10px; font-weight: bold; }\n\n .tsa-badge.benefit { background: #fff3cd; color: #856404; border: 1px solid #ffc107; }\n\n .tsa-divider { border: none; border-top: 1px solid #eee; margin: 6px 14px; }\n\n .tsa-footer {\n\n padding: 10px 14px; display: flex; justify-content: space-between;\n\n align-items: center; border-top: 1px solid #eee; background: #fafafa;\n\n border-radius: 0 0 12px 12px;\n\n }\n\n .tsa-updated { font-size: 10px; color: #888; }\n\n .tsa-refresh {\n\n font-size: 11px; background: #4a6fa5; border: none;\n\n color: #fff; border-radius: 6px; padding: 5px 12px; cursor: pointer;\n\n font-family: Arial, sans-serif; font-weight: bold;\n\n }\n\n .tsa-refresh:hover { background: #3a5f95; }\n\n .tsa-loading { padding: 30px; text-align: center; color: #888; font-size: 12px; }\n\n @keyframes tsa-spin { to { transform: rotate(360deg); } }\n\n .tsa-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: tsa-spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; opacity: 0.6; }\n\n .tsa-error { padding: 16px; color: #cc2222; font-size: 11px; text-align: center; }\n\n /* DARK MODE */\n\n #tsa-overlay.tsa-dark {\n\n background: #0f0f1a; border-color: #3a3a6a; color: #c8c8d8;\n\n }\n\n #tsa-overlay.tsa-dark::-webkit-scrollbar-thumb { background: #3a3a6a; }\n\n #tsa-overlay.tsa-dark .tsa-header { background: #0f0f1a; border-color: #2a2a4a; }\n\n #tsa-overlay.tsa-dark .tsa-stats { border-color: #2a2a4a; }\n\n #tsa-overlay.tsa-dark .tsa-stat { background: #1a1a2e; border-color: #2a2a4a; }\n\n #tsa-overlay.tsa-dark .tsa-stat-label { color: #888; }\n\n #tsa-overlay.tsa-dark .tsa-stat-value { color: #e0e0ff; }\n\n #tsa-overlay.tsa-dark .tsa-section-title { color: #888; }\n\n #tsa-overlay.tsa-dark .tsa-detail { color: #888; }\n\n #tsa-overlay.tsa-dark .tsa-row.buy { background: rgba(76,255,145,0.08); border-color: rgba(76,255,145,0.2); }\n\n #tsa-overlay.tsa-dark .tsa-row.sell { background: rgba(255,76,106,0.08); border-color: rgba(255,76,106,0.2); }\n\n #tsa-overlay.tsa-dark .tsa-row.hold { background: rgba(160,160,255,0.05); border-color: rgba(160,160,255,0.1); }\n\n #tsa-overlay.tsa-dark .tsa-row.buy:active { background: rgba(76,255,145,0.18); }\n\n #tsa-overlay.tsa-dark .tsa-row.sell:active { background: rgba(255,76,106,0.18); }\n\n #tsa-overlay.tsa-dark .tsa-row.hold:active { background: rgba(160,160,255,0.14); }\n\n #tsa-overlay.tsa-dark .tsa-symbol.buy { color: #4cff91; }\n\n #tsa-overlay.tsa-dark .tsa-symbol.sell { color: #ff4c6a; }\n\n #tsa-overlay.tsa-dark .tsa-symbol.hold { color: #a0a0ff; }\n\n #tsa-overlay.tsa-dark .tsa-stat-value.green { color: #4cff91; }\n\n #tsa-overlay.tsa-dark .tsa-stat-value.red { color: #ff4c6a; }\n\n #tsa-overlay.tsa-dark .tsa-divider { border-color: #2a2a4a; }\n\n #tsa-overlay.tsa-dark .tsa-footer { background: #0f0f1a; border-color: #2a2a4a; }\n\n #tsa-overlay.tsa-dark .tsa-updated { color: #666; }\n\n #tsa-overlay.tsa-dark .tsa-loading { color: #666; }\n\n #tsa-overlay.tsa-dark .tsa-close { color: #888; }\n\n #tsa-overlay.tsa-dark .tsa-close:hover { color: #aaa; }\n\n #tsa-overlay.tsa-dark .tsa-theme-btn { color: #c8c8d8; opacity: 1; }\n\n #tsa-overlay.tsa-dark .tsa-theme-btn:hover { color: #ffffff; }\n\n #tsa-overlay.tsa-dark .tsa-title { color: #7a9fd4; }\n\n \\n"
// ============================================================
// ROI PLANNER
// ============================================================
var ROI_TABLE = [
{sym:"SYM",tier:"T1",cost:353970000,payout:4153424,freq:7,type:"variable",item:370},
{sym:"FHG",tier:"T1",cost:1735720000,payout:12390647,freq:7,type:"variable",item:367},
{sym:"TCT",tier:"T1",cost:32121000,payout:1000000,freq:31,type:"fixed",item:0},
{sym:"PRN",tier:"T1",cost:614190000,payout:4019972,freq:7,type:"variable",item:366},
{sym:"SYM",tier:"T2",cost:707940000,payout:4153424,freq:7,type:"variable",item:370},
{sym:"GRN",tier:"T1",cost:154005000,payout:4000000,freq:31,type:"fixed",item:0},
{sym:"IOU",tier:"T1",cost:537810000,payout:12000000,freq:31,type:"fixed",item:0},
{sym:"THS",tier:"T1",cost:58455000,payout:272431,freq:7,type:"variable",item:365},
{sym:"MUN",tier:"T1",cost:2764800000,payout:12705756,freq:7,type:"variable",item:818},
{sym:"PTS",tier:"T1",cost:770100000,payout:3000000,freq:7,type:"volatile",item:0},
{sym:"TMI",tier:"T1",cost:1395240000,payout:25000000,freq:31,type:"fixed",item:0},
{sym:"HRG",tier:"T1",cost:2716600000,payout:45456058,freq:31,type:"random",item:0},
{sym:"EWM",tier:"T1",cost:287840000,payout:1080642,freq:7,type:"variable",item:364},
{sym:"FHG",tier:"T2",cost:3471440000,payout:12390647,freq:7,type:"variable",item:367},
{sym:"TCT",tier:"T2",cost:64242000,payout:1000000,freq:31,type:"fixed",item:0},
{sym:"PRN",tier:"T2",cost:1228380000,payout:4019972,freq:7,type:"variable",item:366},
{sym:"TSB",tier:"T1",cost:3538500000,payout:50000000,freq:31,type:"fixed",item:0},
{sym:"LSC",tier:"T1",cost:272735000,payout:861423,freq:7,type:"variable",item:369},
{sym:"SYM",tier:"T3",cost:1415880000,payout:4153424,freq:7,type:"variable",item:370},
{sym:"GRN",tier:"T2",cost:308010000,payout:4000000,freq:31,type:"fixed",item:0},
{sym:"CNC",tier:"T1",cost:6570750000,payout:80000000,freq:31,type:"fixed",item:0},
{sym:"IOU",tier:"T2",cost:1075620000,payout:12000000,freq:31,type:"fixed",item:0},
{sym:"ASS",tier:"T1",cost:356470000,payout:894596,freq:7,type:"variable",item:817},
{sym:"THS",tier:"T2",cost:116910000,payout:272431,freq:7,type:"variable",item:365},
{sym:"MUN",tier:"T2",cost:5529600000,payout:12705756,freq:7,type:"variable",item:818},
{sym:"PTS",tier:"T2",cost:1540200000,payout:3000000,freq:7,type:"volatile",item:0},
{sym:"TMI",tier:"T2",cost:2790480000,payout:25000000,freq:31,type:"fixed",item:0},
{sym:"HRG",tier:"T2",cost:5433200000,payout:45456058,freq:31,type:"random",item:0},
{sym:"EWM",tier:"T2",cost:575680000,payout:1080642,freq:7,type:"variable",item:364},
{sym:"FHG",tier:"T3",cost:6942880000,payout:12390647,freq:7,type:"variable",item:367},
{sym:"TCT",tier:"T3",cost:128484000,payout:1000000,freq:31,type:"fixed",item:0},
{sym:"TCC",tier:"T1",cost:3850875000,payout:29526634,freq:31,type:"variable",item:0},
{sym:"PRN",tier:"T3",cost:2456760000,payout:4019972,freq:7,type:"variable",item:366},
{sym:"TSB",tier:"T2",cost:7077000000,payout:50000000,freq:31,type:"fixed",item:0},
{sym:"LSC",tier:"T2",cost:545470000,payout:861423,freq:7,type:"variable",item:369},
{sym:"SYM",tier:"T4",cost:2831760000,payout:4153424,freq:7,type:"variable",item:370},
{sym:"GRN",tier:"T3",cost:616020000,payout:4000000,freq:31,type:"fixed",item:0},
{sym:"CNC",tier:"T2",cost:13141500000,payout:80000000,freq:31,type:"fixed",item:0},
{sym:"IOU",tier:"T3",cost:2151240000,payout:12000000,freq:31,type:"fixed",item:0},
{sym:"ASS",tier:"T2",cost:712940000,payout:894596,freq:7,type:"variable",item:817},
{sym:"THS",tier:"T3",cost:233820000,payout:272431,freq:7,type:"variable",item:365},
{sym:"MUN",tier:"T3",cost:11059200000,payout:12705756,freq:7,type:"variable",item:818},
{sym:"PTS",tier:"T3",cost:3080400000,payout:3000000,freq:7,type:"volatile",item:0},
{sym:"TMI",tier:"T3",cost:5580960000,payout:25000000,freq:31,type:"fixed",item:0},
{sym:"HRG",tier:"T3",cost:10866400000,payout:45456058,freq:31,type:"random",item:0},
{sym:"EWM",tier:"T3",cost:1151360000,payout:1080642,freq:7,type:"variable",item:364},
{sym:"FHG",tier:"T4",cost:13885760000,payout:12390647,freq:7,type:"variable",item:367},
{sym:"TCT",tier:"T4",cost:256968000,payout:1000000,freq:31,type:"fixed",item:0},
{sym:"TCC",tier:"T2",cost:7701750000,payout:29526634,freq:31,type:"variable",item:0},
{sym:"PRN",tier:"T4",cost:4913520000,payout:4019972,freq:7,type:"variable",item:366},
{sym:"TSB",tier:"T3",cost:14154000000,payout:50000000,freq:31,type:"fixed",item:0},
{sym:"LSC",tier:"T3",cost:1090940000,payout:861423,freq:7,type:"variable",item:369},
{sym:"SYM",tier:"T5",cost:5663520000,payout:4153424,freq:7,type:"variable",item:370},
{sym:"GRN",tier:"T4",cost:1232040000,payout:4000000,freq:31,type:"fixed",item:0},
{sym:"CNC",tier:"T3",cost:26283000000,payout:80000000,freq:31,type:"fixed",item:0},
{sym:"IOU",tier:"T4",cost:4302480000,payout:12000000,freq:31,type:"fixed",item:0},
{sym:"ASS",tier:"T3",cost:1425880000,payout:894596,freq:7,type:"variable",item:817},
{sym:"THS",tier:"T4",cost:467640000,payout:272431,freq:7,type:"variable",item:365},
{sym:"LAG",tier:"T1",cost:353557500,payout:203827,freq:7,type:"variable",item:368},
{sym:"MUN",tier:"T4",cost:22118400000,payout:12705756,freq:7,type:"variable",item:818},
{sym:"PTS",tier:"T4",cost:6160800000,payout:3000000,freq:7,type:"volatile",item:0},
{sym:"TMI",tier:"T4",cost:11161920000,payout:25000000,freq:31,type:"fixed",item:0},
{sym:"HRG",tier:"T4",cost:21732800000,payout:45456058,freq:31,type:"random",item:0},
{sym:"EWM",tier:"T4",cost:2302720000,payout:1080642,freq:7,type:"variable",item:364},
{sym:"FHG",tier:"T5",cost:27771520000,payout:12390647,freq:7,type:"variable",item:367},
{sym:"TCT",tier:"T5",cost:513936000,payout:1000000,freq:31,type:"fixed",item:0},
{sym:"TCC",tier:"T3",cost:15403500000,payout:29526634,freq:31,type:"variable",item:0},
{sym:"PRN",tier:"T5",cost:9827040000,payout:4019972,freq:7,type:"variable",item:366},
{sym:"TSB",tier:"T4",cost:28308000000,payout:50000000,freq:31,type:"fixed",item:0},
{sym:"LSC",tier:"T4",cost:2181880000,payout:861423,freq:7,type:"variable",item:369},
{sym:"SYM",tier:"T6",cost:11327040000,payout:4153424,freq:7,type:"variable",item:370},
{sym:"GRN",tier:"T5",cost:2464080000,payout:4000000,freq:31,type:"fixed",item:0},
{sym:"CNC",tier:"T4",cost:52566000000,payout:80000000,freq:31,type:"fixed",item:0},
{sym:"IOU",tier:"T5",cost:8604960000,payout:12000000,freq:31,type:"fixed",item:0},
{sym:"ASS",tier:"T4",cost:2851760000,payout:894596,freq:7,type:"variable",item:817},
{sym:"THS",tier:"T5",cost:935280000,payout:272431,freq:7,type:"variable",item:365},
{sym:"LAG",tier:"T2",cost:707115000,payout:203827,freq:7,type:"variable",item:368},
{sym:"MUN",tier:"T5",cost:44236800000,payout:12705756,freq:7,type:"variable",item:818},
{sym:"PTS",tier:"T5",cost:12321600000,payout:3000000,freq:7,type:"volatile",item:0},
{sym:"TMI",tier:"T5",cost:22323840000,payout:25000000,freq:31,type:"fixed",item:0},
{sym:"HRG",tier:"T5",cost:43465600000,payout:45456058,freq:31,type:"random",item:0},
{sym:"EWM",tier:"T5",cost:4605440000,payout:1080642,freq:7,type:"variable",item:364},
{sym:"FHG",tier:"T6",cost:55543040000,payout:12390647,freq:7,type:"variable",item:367},
{sym:"TCT",tier:"T6",cost:1027872000,payout:1000000,freq:31,type:"fixed",item:0},
{sym:"TCC",tier:"T4",cost:30807000000,payout:29526634,freq:31,type:"variable",item:0},
{sym:"PRN",tier:"T6",cost:19654080000,payout:4019972,freq:7,type:"variable",item:366},
{sym:"TSB",tier:"T5",cost:56616000000,payout:50000000,freq:31,type:"fixed",item:0},
{sym:"LSC",tier:"T5",cost:4363760000,payout:861423,freq:7,type:"variable",item:369}
];
// Lookup map for O(1) ROI_TABLE access: key = "SYM|Tn"
var ROI_MAP = (function() {
var m = {};
ROI_TABLE.forEach(function(e) { m[e.sym + "|" + e.tier] = e; });
return m;
})();
// Item IDs with sellable market value
var ITEM_IDS = [364, 365, 366, 367, 368, 369, 370, 817, 818];
// PTS gives 100 points = $3M fixed
var PTS_VALUE = 3000000;
var roiSkipped = (function() { try { return JSON.parse(localStorage.getItem("tsa_roi_skipped") || "[]") || []; } catch(e) { return []; } })();
var itemPrices = {}; // cache: itemId -> price
function fmRoi(n) {
if (n >= 1e9) return "$" + (n/1e9).toFixed(2) + "B";
if (n >= 1e6) return "$" + (n/1e6).toFixed(2) + "M";
if (n >= 1e3) return "$" + (n/1e3).toFixed(0) + "K";
return "$" + n.toFixed(0);
}
function fetchItemPrice(itemId, cb) {
if (itemPrices[itemId] !== undefined) { cb(itemPrices[itemId]); return; }
var url = "https://api.torn.com/market/" + itemId + "?selections=itemmarket&key=" + getTornKey();
GM_xmlhttpRequest({
method: "GET", url: url,
onload: function(r) {
try {
var d = JSON.parse(r.responseText);
var listings = d.itemmarket && d.itemmarket.listings ? d.itemmarket.listings : [];
if (listings.length > 0) {
// Use lowest price
var lowest = listings.reduce(function(a,b){ return a.price < b.price ? a : b; });
itemPrices[itemId] = lowest.price;
cb(lowest.price);
} else { itemPrices[itemId] = 0; cb(0); }
} catch(e) { itemPrices[itemId] = 0; cb(0); }
},
onerror: function() { itemPrices[itemId] = 0; cb(0); }
});
}
function fetchAllItemPrices(cb) {
var remaining = ITEM_IDS.length;
var done = false;
function finish() { if (!done) { done = true; cb(); } }
setTimeout(finish, 10000); // failsafe: call cb after 10s even if a request never returns
ITEM_IDS.forEach(function(id) {
fetchItemPrice(id, function() {
remaining--;
if (remaining === 0) finish();
});
});
}
function getItemValue(entry) {
if (entry.sym === "PTS") return PTS_VALUE;
if (entry.item && itemPrices[entry.item]) return itemPrices[entry.item];
return 0;
}
// Calculate Bollinger Bands using all stored price history
// Returns { upper, middle, lower, pctB } or null if insufficient data
// pctB = position within bands: 0 = at lower, 1 = at upper, <0 = below lower
function calcBollingerBands(sym, history, livePrice) {
var entries = history ? history[sym.toUpperCase()] : null;
if (!entries || entries.length < 20) return null;
var prices = entries.map(function(e) { return e.price; });
// Use last 20 prices for SMA and stddev
var period = 20;
var slice = prices.slice(-period);
var sma = slice.reduce(function(a, b) { return a + b; }, 0) / period;
var variance = slice.reduce(function(sum, p) { return sum + Math.pow(p - sma, 2); }, 0) / period;
var stddev = Math.sqrt(variance);
var upper = sma + 2 * stddev;
var lower = sma - 2 * stddev;
var range = upper - lower;
if (range === 0) return null; // no volatility — bands are meaningless
var pctB = (livePrice - lower) / range;
return { upper: upper, middle: sma, lower: lower, pctB: pctB };
}
// Returns { macd, signal, histogram, crossover } or null if insufficient data
function calcMACD(sym, history) {
var entries = history ? history[sym.toUpperCase()] : null;
if (!entries || entries.length < 35) return null;
var sorted = entries.slice().sort(function(a, b) { return a.ts - b.ts; });
var prices = sorted.map(function(e) { return e.price; });
if (prices.length < 35) return null;
function calcEMA(data, period) {
var k = 2 / (period + 1);
var ema = [data[0]];
for (var i = 1; i < data.length; i++) {
ema.push(data[i] * k + ema[i-1] * (1 - k));
}
return ema;
}
var ema12 = calcEMA(prices, 12);
var ema26 = calcEMA(prices, 26);
var macdLine = ema12.map(function(v, i) { return v - ema26[i]; });
var signalLine = calcEMA(macdLine, 9); // signal line computed over full macdLine for aligned indices
var lastMacd = macdLine[macdLine.length - 1];
var lastSignal = signalLine[signalLine.length - 1];
var prevMacd = macdLine[macdLine.length - 2];
var prevSignal = signalLine[signalLine.length - 2];
// Bullish crossover: MACD crossed above signal line
var crossover = prevMacd < prevSignal && lastMacd >= lastSignal;
return {
macd: lastMacd,
signal: lastSignal,
histogram: lastMacd - lastSignal,
crossover: crossover
};
}
// Low-level RSI from a price array (min 15 prices, 14-period Wilder)
function rsiFromPrices(prices) {
if (!prices || prices.length < 15) return null;
var changes = [];
for (var i = 1; i < prices.length; i++) changes.push(prices[i] - prices[i - 1]);
var period = 14;
var gains = 0, losses = 0;
for (var j = 0; j < period; j++) {
if (changes[j] >= 0) gains += changes[j];
else losses += Math.abs(changes[j]);
}
var avgGain = gains / period, avgLoss = losses / period;
for (var k = period; k < changes.length; k++) {
var g = changes[k] >= 0 ? changes[k] : 0;
var l = changes[k] < 0 ? Math.abs(changes[k]) : 0;
avgGain = (avgGain * 13 + g) / 14;
avgLoss = (avgLoss * 13 + l) / 14;
}
if (avgGain === 0 && avgLoss === 0) return null;
if (avgLoss === 0) return 100;
return 100 - (100 / (1 + avgGain / avgLoss));
}
// Calculate RSI using all stored price history (kept for backward compat)
function calcRSI(sym, history) {
var entries = history ? history[sym.toUpperCase()] : null;
if (!entries || entries.length < 15) return null;
return rsiFromPrices(entries.map(function(e) { return e.price; }));
}
// Torn-specific RSI context: returns current RSI + its percentile within this
// stock's own historical RSI range. Uses 28-price windows (28h of hourly data)
// stepped every 4 prices through history — auto-calibrates to each stock's
// normal RSI behaviour instead of relying on generic 30/70 real-market thresholds.
function calcRSIContext(sym, history) {
var entries = history ? history[sym.toUpperCase()] : null;
if (!entries || entries.length < 28) return null;
var sorted = entries.slice().sort(function(a, b) { return a.ts - b.ts; });
var prices = sorted.map(function(e) { return e.price; });
var current = rsiFromPrices(prices.slice(-28));
if (current === null) return null;
// Build historical RSI sample: one value per 4-price step
var historicalRSI = [];
for (var i = 28; i <= prices.length; i += 4) {
var r = rsiFromPrices(prices.slice(i - 28, i));
if (r !== null) historicalRSI.push(r);
}
if (historicalRSI.length < 5) {
// Not enough history for percentile — return RSI only
return { rsi: current, percentile: null };
}
historicalRSI.sort(function(a, b) { return a - b; });
var below = historicalRSI.filter(function(r) { return r <= current; }).length;
return { rsi: current, percentile: (below / historicalRSI.length) * 100 };
}
// Bollinger Band width context for Torn stocks.
// BB width = (2 * stddev / SMA) * 100 over a 20-price window.
// Returns the current width's percentile within the stock's own historical
// BB widths so the caller knows if the stock is in a squeeze (narrow band,
// low volatility — breakout pending) or an active/expanded band.
// Percentile is always relative to this stock's own history, not a fixed scale.
function calcBBWidth(sym, history) {
var entries = history ? history[sym.toUpperCase()] : null;
if (!entries || entries.length < 20) return null;
var sorted = entries.slice().sort(function(a, b) { return a.ts - b.ts; });
var prices = sorted.map(function(e) { return e.price; });
function bbWidthFromSlice(slice) {
if (slice.length < 20) return null;
var n = 20;
var window = slice.slice(-n);
var sma = window.reduce(function(s, v) { return s + v; }, 0) / n;
if (sma <= 0) return null;
var variance = window.reduce(function(s, v) { return s + (v - sma) * (v - sma); }, 0) / n;
var stddev = Math.sqrt(variance);
return (2 * stddev / sma) * 100;
}
var current = bbWidthFromSlice(prices);
if (current === null) return null;
// Historical sample: one BB width every 4 prices
var historicalWidths = [];
for (var i = 20; i <= prices.length; i += 4) {
var w = bbWidthFromSlice(prices.slice(0, i));
if (w !== null) historicalWidths.push(w);
}
if (historicalWidths.length < 5) return { width: current, percentile: null };
historicalWidths.sort(function(a, b) { return a - b; });
var below = historicalWidths.filter(function(w) { return w <= current; }).length;
var percentile = (below / historicalWidths.length) * 100;
var label;
if (percentile <= 15) label = "Squeeze";
else if (percentile <= 35) label = "Low volatility";
else if (percentile <= 65) label = "Normal";
else if (percentile <= 85) label = "Active";
else label = "High volatility";
return { width: current, percentile: percentile, label: label };
}
// Uses BENEFIT_REQ (fixed share counts) and dividend.increment from API
// Next tier total shares = BENEFIT_REQ × (2^nextIncrement - 1)
// Next tier cost = sharesNeeded × live_price
function calcNextTier(sym, ownedMap, raw) {
var req = BENEFIT_REQ[sym];
if (!req) return null;
var liveEntry = raw ? raw.find(function(x) { return x.stock === sym; }) : null;
var livePrice = liveEntry ? (parseFloat(liveEntry.price) || 0) : 0;
if (livePrice <= 0) return null;
var o = ownedMap[sym];
var currentIncrement = o ? (o.dividend_increment || 0) : 0;
var currentShares = o ? (o.shares || 0) : 0;
var nextIncrement = currentIncrement + 1;
var totalSharesNeeded = (Math.pow(2, nextIncrement) - 1) * req;
var sharesNeeded = Math.max(0, totalSharesNeeded - currentShares);
var cost = sharesNeeded * livePrice;
return {
sym: sym,
currentIncrement: currentIncrement,
nextIncrement: nextIncrement,
totalSharesNeeded: totalSharesNeeded,
sharesNeeded: sharesNeeded,
cost: cost,
livePrice: livePrice
};
}
// ownedMap: from buildOwnedMap, raw: from tornsy for live prices
function calcWeeklyIncome(ownedMap, raw, extraEntry) {
var weeklyTotal = 0;
// Iterate over all owned stocks with benefit shares
Object.keys(ownedMap).forEach(function(sym) {
var o = ownedMap[sym];
if (!o.has_dividend || o.benefit_shares <= 0) return;
var increments = o.dividend_increment || 0;
if (increments <= 0) return;
// Find matching ROI table entries
ROI_TABLE.forEach(function(entry) {
if (entry.sym !== sym) return;
var tierNum = parseInt(entry.tier.replace("T",""), 10);
if (tierNum !== increments) return;
// Payout per 7 days
var payoutPerDay = entry.payout / entry.freq;
var itemVal = getItemValue(entry);
var itemPerDay = itemVal / entry.freq;
weeklyTotal += (payoutPerDay + itemPerDay) * 7;
});
});
// Add extra entry (bridgebuilder)
if (extraEntry) {
var itemVal = getItemValue(extraEntry);
var payoutPerDay = extraEntry.payout / extraEntry.freq;
var itemPerDay = itemVal / extraEntry.freq;
weeklyTotal += (payoutPerDay + itemPerDay) * 7;
}
return weeklyTotal;
}
// Calculate days until target is affordable with given weekly income + capital
function daysToAfford(target, capital, weeklyIncome) {
if (capital >= target) return 0;
if (weeklyIncome <= 0) return Infinity;
var needed = target - capital;
var dailyIncome = weeklyIncome / 7;
return Math.ceil(needed / dailyIncome);
}
function renderROIPlanner(ownedMap, raw, cashBalance, armoryFunds) {
if (!armoryFunds) armoryFunds = 0;
// Calculate swing capital
var swingCapital = 0;
var swingDetails = [];
Object.keys(ownedMap).forEach(function(sym) {
var o = ownedMap[sym];
if (o.swing_shares <= 0) return;
var liveEntry = raw ? raw.find(function(x){ return x.stock === sym; }) : null;
if (!liveEntry) return;
var livePrice = parseFloat(liveEntry.price) || 0;
var val = livePrice * o.swing_shares;
swingCapital += val;
if (val > 0) swingDetails.push({sym: sym, val: val});
});
var totalCapital = cashBalance + swingCapital + armoryFunds;
// Find owned benefit blocks from the ROI table
var ownedEntries = ROI_TABLE.filter(function(entry) {
var o = ownedMap[entry.sym];
if (!o || !o.has_dividend || o.benefit_shares <= 0) return false;
var tierNum = parseInt(entry.tier.replace("T",""), 10);
// Use Torn's own increment field
return tierNum <= o.dividend_increment;
});
// Weekly income from current benefit blocks
var weeklyIncome = calcWeeklyIncome(ownedMap, raw, null);
// Find next 3 recommended purchases using dynamic tier costs
var ownedKeys = ownedEntries.map(function(e){ return e.sym + e.tier; });
// Build dynamic next tier list for all 35 stocks with benefit data
var benefitSyms = Object.keys(BENEFIT_REQ);
var dynamicNextTiers = [];
benefitSyms.forEach(function(sym) {
var tierInfo = calcNextTier(sym, ownedMap, raw);
if (!tierInfo || tierInfo.cost <= 0) return;
// Find payout data from ROI_MAP for this next increment
var payoutEntry = ROI_MAP[sym + "|T" + tierInfo.nextIncrement] || null;
// If no ROI_MAP entry — passive stock, skip (no sellable payout)
dynamicNextTiers.push({
sym: sym,
tierInfo: tierInfo,
payoutEntry: payoutEntry,
cost: tierInfo.cost,
roi: payoutEntry ? (payoutEntry.payout / tierInfo.cost * (365 / payoutEntry.freq) * 100) : 0
});
});
// Sort by ROI descending, filter out skipped
dynamicNextTiers = dynamicNextTiers.filter(function(e) {
var key = e.sym + "T" + e.tierInfo.nextIncrement;
return roiSkipped.indexOf(key) < 0;
});
dynamicNextTiers.sort(function(a, b) { return b.roi - a.roi; });
// Target = best ROI entry regardless of affordability
var target = dynamicNextTiers[0] || null;
var nextEntries = target ? [target] : [];
var nextEntry = target;
// Bridgebuilder chain: buy multiple bridges you can afford (keep them, don't sell),
// each one adds dividend income that accelerates reaching Next Move.
var bridgeChain = [];
var daysWait = target ? daysToAfford(target.cost, totalCapital, weeklyIncome) : 0;
var daysBaseline = daysWait;
if (target) {
var chainCap = totalCapital;
var chainIncome = weeklyIncome;
// Candidates: not the target itself, not skipped, must have dividend income
var allBridgeCandidates = dynamicNextTiers.filter(function(e) {
var key = e.sym + "T" + e.tierInfo.nextIncrement;
if (roiSkipped.indexOf(key) >= 0) return false;
if (e.sym === target.sym && e.tierInfo.nextIncrement === target.tierInfo.nextIncrement) return false;
if (!e.payoutEntry) return false;
var itemVal = getItemValue(e.payoutEntry);
return ((e.payoutEntry.payout + itemVal) / e.payoutEntry.freq * 7) > 0;
});
allBridgeCandidates.forEach(function(e) {
var itemVal = getItemValue(e.payoutEntry);
var extraIncome = (e.payoutEntry.payout + itemVal) / e.payoutEntry.freq * 7;
if (e.cost <= chainCap) {
// Affordable now — buy it, reduce capital, increase income stream
chainCap -= e.cost;
chainIncome += extraIncome;
bridgeChain.push({
sym: e.sym, tier: "T" + e.tierInfo.nextIncrement,
cost: e.cost, extraIncome: extraIncome, roi: e.roi,
status: "now", daysUntil: 0
});
}
// "Later" bridges added separately below, sorted by soonest
});
// After buying all "now" bridges, find the next 2 soonest affordable ones
// that actually save days vs just waiting — these are the ones to save up for
var laterCandidates = [];
allBridgeCandidates.forEach(function(e) {
if (bridgeChain.some(function(b) { return b.sym === e.sym && b.tier === ("T" + e.tierInfo.nextIncrement); })) return; // already handled as "now"
var itemVal = getItemValue(e.payoutEntry);
var extraIncome = (e.payoutEntry.payout + itemVal) / e.payoutEntry.freq * 7;
var daysUntil = daysToAfford(e.cost, chainCap, chainIncome);
if (daysUntil === Infinity || daysUntil > 365) return;
// Simulate buying this bridge after daysUntil: does it save days to target?
var simCap = chainCap + (chainIncome / 7 * daysUntil) - e.cost;
var simIncome = chainIncome + extraIncome;
var daysAfter = daysUntil + daysToAfford(target.cost, simCap, simIncome);
var saved = daysBaseline - daysAfter;
if (saved <= 0) return; // doesn't help
laterCandidates.push({
sym: e.sym, tier: "T" + e.tierInfo.nextIncrement,
cost: e.cost, extraIncome: extraIncome, roi: e.roi,
status: "later", daysUntil: daysUntil, daysSaved: saved
});
});
// Sort by soonest affordable, keep top 2
laterCandidates.sort(function(a, b) { return a.daysUntil - b.daysUntil; });
laterCandidates.slice(0, 2).forEach(function(b) { bridgeChain.push(b); });
// Recompute goal with all "now" bridges applied (capital reduced, income boosted)
var daysWithBridges = daysToAfford(target.cost, chainCap, chainIncome);
// Sell candidates: owned benefit blocks with lower ROI than target
// ROI of target
var targetRoi = target.roi || 0;
var sellCandidates = [];
ownedEntries.forEach(function(e) {
var itemVal = getItemValue(e);
var weekly = e.payout ? (e.payout + itemVal) / (e.freq || 7) * 7 : 0;
var liveEntry = raw ? raw.find(function(x) { return x.stock === e.sym; }) : null;
var livePrice = liveEntry ? (parseFloat(liveEntry.price) || 0) : 0;
var o = ownedMap[e.sym];
var req = BENEFIT_REQ[e.sym] || 0;
// Use this entry's specific tier number, not the overall increment
var entryTierNum = parseInt(e.tier.replace("T",""), 10) || 0;
// Shares belonging to THIS tier only: (2^n - 2^(n-1)) * BENEFIT_REQ
var tierShares = entryTierNum > 0 ? (Math.pow(2, entryTierNum) - Math.pow(2, entryTierNum - 1)) * req : 0;
var saleValue = tierShares * livePrice * 0.999;
// Live cost of this tier's shares
var liveTierCost = tierShares * livePrice;
var entryRoi = liveTierCost > 0 && e.payout ? (e.payout / liveTierCost * (365 / (e.freq || 7)) * 100) : 0;
if (entryRoi > 0 && entryRoi < targetRoi && saleValue > 0) {
sellCandidates.push({
sym: e.sym,
tier: e.tier,
roi: entryRoi,
saleValue: saleValue,
weekly: weekly
});
}
});
sellCandidates.sort(function(a, b) { return a.roi - b.roi; }); // lowest ROI first
}
// Build HTML
var isDark = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
var c = isDark ? {
bg:"#0f0f1a", border:"#2a2a4a", bg2:"#0d0d18", bg3:"#0c0c16",
text:"#c8c8d8", muted:"#7a7a9a", mono:"JetBrains Mono,monospace",
blue:"#7a9fd4", green:"#4cff91", red:"#cc4444", owned_bg:"rgba(40,180,100,0.07)",
owned_border:"rgba(76,255,145,0.4)", next_bg:"rgba(74,111,165,0.1)",
next_border:"rgba(122,159,212,0.5)", skip_bg:"rgba(180,40,40,0.06)",
skip_border:"rgba(255,80,80,0.3)", neutral:"#7a7a9a", row_border:"#13131f",
divider:"#1a1a2e", tag_bg:"rgba(120,140,200,0.08)", tag_border:"rgba(120,140,200,0.15)", tag_text:"#8898bb",
bridge_bg:"rgba(90,180,80,0.09)", bridge_border:"rgba(76,255,145,0.45)", bridge:"#5ab450"
} : {
bg:"#ffffff", border:"#ddd", bg2:"#f7f9fc", bg3:"#f0f4ff",
text:"#222", muted:"#666", mono:"Arial,sans-serif",
blue:"#4a6fa5", green:"#1a8a45", red:"#cc2222", owned_bg:"#edfaf3",
owned_border:"#a8e6c0", next_bg:"#f0f4ff", next_border:"#c0d0ff",
skip_bg:"#fff0f0", skip_border:"#ffb3b3", neutral:"#aaa", row_border:"#eee",
divider:"#eee", tag_bg:"#f0f4ff", tag_border:"#c0d0ff", tag_text:"#4a6fa5",
bridge_bg:"#edfff0", bridge_border:"#80c880", bridge:"#1a7a35"
};
var s = 'font-family:' + c.mono + ';';
// Capital bar
var swingTagsHtml = swingDetails.slice(0,4).map(function(d){
return '<span style="font-size:9px;padding:2px 6px;border-radius:8px;background:' + c.tag_bg + ';border:1px solid ' + c.tag_border + ';color:' + c.tag_text + ';' + s + 'margin-right:4px">' + d.sym + ' ' + fmRoi(d.val) + '</span>';
}).join('');
if (armoryFunds > 0) {
swingTagsHtml = '<span style="font-size:9px;padding:2px 6px;border-radius:8px;background:' + c.tag_bg + ';border:1px solid ' + c.tag_border + ';color:' + c.tag_text + ';' + s + 'margin-right:4px">Armory ' + fmRoi(armoryFunds) + '</span>' + swingTagsHtml;
}
if (cashBalance > 0) {
swingTagsHtml = '<span style="font-size:9px;padding:2px 6px;border-radius:8px;background:' + c.tag_bg + ';border:1px solid ' + c.tag_border + ';color:' + c.tag_text + ';' + s + 'margin-right:4px">Cash ' + fmRoi(cashBalance) + '</span>' + swingTagsHtml;
}
var html = '<div style="padding:8px 14px;border-bottom:1px solid ' + c.divider + ';background:' + c.bg2 + '">' +
'<div style="font-size:9px;letter-spacing:0.1em;color:' + c.muted + ';text-transform:uppercase;margin-bottom:3px">Available capital</div>' +
'<div style="' + s + 'font-size:14px;font-weight:700;color:' + c.text + '">' + fmRoi(totalCapital) + '</div>' +
'<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:4px">' + swingTagsHtml + '</div>' +
'</div>';
// Next move section
if (target) {
var nmRow = function(label, val, color) {
return '<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:3px">' +
'<span style="font-size:9px;color:' + c.muted + ';text-transform:uppercase;letter-spacing:0.08em;' + s + '">' + label + '</span>' +
'<span style="font-size:10px;color:' + (color||c.text) + ';' + s + ';text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:65%">' + val + '</span>' +
'</div>';
};
html += '<div style="padding:10px 14px;border-bottom:2px solid ' + c.divider + ';background:' + c.bg3 + '">';
html += '<div style="font-size:9px;letter-spacing:0.12em;text-transform:uppercase;color:' + c.blue + ';' + s + ';font-weight:700;margin-bottom:8px">💡 Next move</div>';
// Target
var shortBy = Math.max(0, target.cost - totalCapital);
var targetTier = target.tier || ("T" + target.tierInfo.nextIncrement);
html += nmRow("Target", target.sym + " " + targetTier + " · " + (target.roi || 0).toFixed(2) + "% ROI", c.blue);
html += nmRow("Cost", fmRoi(target.cost) + (shortBy > 0 ? " · short " + fmRoi(shortBy) : " ✓"), shortBy > 0 ? c.red : c.green);
html += nmRow("Available", fmRoi(totalCapital), c.text);
// Bridgebuilder chain — buy and hold, each block adds dividend income
html += '<div style="border-top:1px solid ' + c.divider + ';margin:6px 0"></div>';
html += '<div style="font-size:9px;color:#5a7a4a;letter-spacing:0.08em;' + s + ';margin-bottom:5px">🔗 Bridgebuilder</div>';
var hasSell = sellCandidates && sellCandidates.length > 0;
if (bridgeChain.length > 0) {
bridgeChain.forEach(function(b) {
var statusColor = b.status === "now" ? c.green : c.muted;
var statusLabel = b.status === "now"
? "✓ Buy now"
: "in ~" + b.daysUntil + "d · saves " + b.daysSaved + "d";
var roiStr = b.roi > 0 ? " · " + b.roi.toFixed(1) + "%" : "";
html += nmRow(
"🔗 " + b.sym + " " + b.tier + roiStr,
fmRoi(b.cost) + " · +" + fmRoi(b.extraIncome) + "/7d · " + statusLabel,
statusColor
);
});
var savedDays = daysBaseline - daysWithBridges;
if (savedDays > 0) {
html += nmRow(
"Goal with bridges",
"~" + daysWithBridges + "d (saves " + savedDays + "d)",
c.green
);
}
} else {
html += nmRow("", "No bridgebuilder options", c.muted);
}
if (hasSell) {
html += '<div style="margin:4px 0;border-top:1px dashed ' + c.divider + '"></div>';
html += '<div style="font-size:9px;color:#5a6a7a;letter-spacing:0.08em;' + s + ';margin-bottom:5px">💸 Alt: Sell lower ROI blocks</div>';
var cumulativeSale = 0;
var cumulativeLostIncome = 0;
sellCandidates.slice(0, 4).forEach(function(sc) {
cumulativeSale += sc.saleValue;
cumulativeLostIncome += sc.weekly;
var daysIfSold = daysToAfford(target.cost - cumulativeSale, totalCapital, weeklyIncome - cumulativeLostIncome);
html += nmRow("Sell " + sc.sym + " " + sc.tier + " · " + sc.roi.toFixed(1) + "%", fmRoi(sc.saleValue) + " → ~" + daysIfSold + "d", c.muted);
});
}
// Alt: Wait
html += '<div style="border-top:1px solid ' + c.divider + ';margin:6px 0"></div>';
html += '<div style="font-size:9px;color:#5a6a7a;letter-spacing:0.08em;' + s + ';margin-bottom:5px">⏱ Alt: Wait</div>';
html += nmRow("Income", fmRoi(weeklyIncome) + " / 7d (current benefits)");
// Income breakdown
var incomeBreakdown = [];
Object.keys(ownedMap).forEach(function(sym) {
var o = ownedMap[sym];
if (!o.has_dividend || o.benefit_shares <= 0) return;
var increments = o.dividend_increment || 0;
if (increments <= 0) return;
ROI_TABLE.forEach(function(entry) {
if (entry.sym !== sym) return;
var tierNum = parseInt(entry.tier.replace("T",""), 10);
if (tierNum !== increments) return;
var itemVal = getItemValue(entry);
var weekly = (entry.payout + itemVal) / entry.freq * 7;
incomeBreakdown.push(sym + " " + fmRoi(weekly));
});
});
if (incomeBreakdown.length > 0) {
html += nmRow("Breakdown", incomeBreakdown.slice(0,3).join(" · "));
if (incomeBreakdown.length > 3) html += nmRow("", incomeBreakdown.slice(3,6).join(" · "));
}
html += nmRow("Goal in", daysWait === 0 ? "Now!" : daysWait === Infinity ? "N/A" : "~" + daysWait + " days", c.text);
html += '</div>';
}
// Table header
html += '<div style="display:grid;grid-template-columns:42px 26px 1fr 54px 32px;gap:4px;padding:5px 14px;font-size:9px;letter-spacing:0.1em;color:' + c.muted + ';text-transform:uppercase;border-bottom:1px solid ' + c.divider + ';' + s + ';position:sticky;top:0;background:' + c.bg + ';z-index:2;">' +
'<span>Stock</span><span>Tier</span><span>Shares needed / Cost</span><span style="text-align:right">ROI</span><span></span></div>';
// Show all benefit stocks: owned tiers first (green), then next tiers sorted by ROI
var tableRows = [];
// Add owned entries
ownedEntries.forEach(function(e) {
var o = ownedMap[e.sym];
// Days remaining = frequency - progress (both already stored from API)
var freq = (o && o.dividend_frequency) || e.freq || 0;
var prog = (o && o.dividend_progress) || 0;
var daysLeft = (freq > 0 && prog >= 0) ? Math.max(0, freq - prog) : -1;
tableRows.push({ sym: e.sym, tier: e.tier, isOwned: true, cost: 0, roi: 0, sharesNeeded: 0, payout: e.payout || 0, freq: freq, daysLeft: daysLeft });
});
// Add next tier for each stock
dynamicNextTiers.forEach(function(e) {
var key = e.sym + "T" + e.tierInfo.nextIncrement;
var isNext = nextEntries.some(function(ne) { return ne.sym === e.sym && ne.tier === "T" + e.tierInfo.nextIncrement; });
var isBridge = bridgeChain.some(function(b) { return b.sym === e.sym && b.tier === "T" + e.tierInfo.nextIncrement; });
var isSkipped = roiSkipped.indexOf(key) >= 0;
tableRows.push({
sym: e.sym,
tier: "T" + e.tierInfo.nextIncrement,
isOwned: false,
isNext: isNext,
isBridge: !!isBridge,
isSkipped: isSkipped,
cost: e.cost,
roi: e.roi,
sharesNeeded: e.tierInfo.sharesNeeded,
payout: e.payoutEntry ? e.payoutEntry.payout : 0,
freq: e.payoutEntry ? e.payoutEntry.freq : 7,
item: e.payoutEntry ? e.payoutEntry.item : 0,
key: key
});
});
tableRows.forEach(function(row) {
var rowBg = row.isOwned ? c.owned_bg : row.isNext ? c.next_bg : row.isBridge ? c.bridge_bg : row.isSkipped ? c.skip_bg : "transparent";
var borderLeft = row.isOwned ? c.owned_border : row.isNext ? c.next_border : row.isBridge ? c.bridge_border : row.isSkipped ? c.skip_border : "transparent";
var symColor = row.isOwned ? c.green : row.isNext ? c.blue : row.isBridge ? c.bridge : row.isSkipped ? c.red : c.neutral;
var roiPct = row.isBridge ? "🔗 " + row.roi.toFixed(2) + "%" : row.roi > 0 ? row.roi.toFixed(2) + "%" : "—";
var itemVal = row.item ? getItemValue({ sym: row.sym, item: row.item }) : 0;
var nextPayoutStr = "";
if (row.isOwned && row.daysLeft >= 0) {
nextPayoutStr = row.daysLeft === 0 ? " · next <1d" : " · next " + row.daysLeft + "d";
}
var nextPayoutColor = (row.isOwned && row.daysLeft >= 0 && row.daysLeft <= 1) ? c.yellow : c.muted;
var costLine = row.isOwned ? "Owned" :
row.sharesNeeded.toLocaleString("en-US") + " shares · " + fmRoi(row.cost);
var skipBtnStyle = 'width:28px;height:28px;border-radius:50%;border:1px solid ' + c.divider + ';background:none;cursor:pointer;font-size:10px;color:' + c.muted + ';display:flex;align-items:center;justify-content:center;justify-self:center;' + (row.isOwned ? 'opacity:0.2;pointer-events:none;' : '');
var skipLabel = row.isSkipped ? "↩" : "✕";
var key = row.key || (row.sym + row.tier);
var buyAttrs = (!row.isOwned && row.sharesNeeded > 0)
? ' class="tsa-roi-buyrow" data-buy-sym="' + row.sym + '" data-buy-shares="' + row.sharesNeeded + '" data-buy-tier="' + row.tier + '" data-buy-state="0"'
: '';
html += '<div data-roi-key="' + key + '"' + buyAttrs + ' style="display:grid;grid-template-columns:42px 26px 1fr 54px 32px;gap:4px;align-items:center;padding:7px 14px;border-bottom:1px solid ' + c.row_border + ';background:' + rowBg + ';border-left:2px solid ' + borderLeft + ';cursor:' + (buyAttrs ? 'pointer' : 'default') + ';transition:background 0.15s">' +
'<span style="' + s + ';font-weight:700;font-size:12px;color:' + symColor + '">' + row.sym + '</span>' +
'<span style="' + s + ';font-size:9px;color:' + c.muted + '">' + row.tier + '</span>' +
'<div style="display:flex;flex-direction:column;gap:1px;overflow:hidden;min-width:0">' +
'<span style="' + s + ';font-size:10px;color:' + c.muted + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + costLine + (nextPayoutStr ? '<span style="color:' + nextPayoutColor + '">' + nextPayoutStr + '</span>' : '') + '</span>' +
'<span style="font-size:9px;color:' + c.muted + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + fmRoi(row.payout) + (itemVal > 0 ? " + " + fmRoi(itemVal) : "") + " / " + row.freq + "d</span>" +
'</div>' +
'<span style="' + s + ';font-size:11px;font-weight:700;text-align:right;color:' + symColor + '">' + roiPct + '</span>' +
'<button class="tsa-roi-skip" data-key="' + key + '" data-owned="' + (row.isOwned?1:0) + '" style="' + skipBtnStyle + '">' + skipLabel + '</button>' +
'</div>';
});
return html;
}
function showROIPlanner(ownedMap, raw) {
var content = document.getElementById("tsa-content");
if (!content) return;
var isDarkNow = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
content.style.background = isDarkNow ? "#0f0f1a" : "#ffffff";
content.style.color = isDarkNow ? "#c8c8d8" : "#222";
content.innerHTML = '<div style="padding:20px;text-align:center;color:' + (isDarkNow ? '#7a7a9a' : '#888') + ';font-size:12px"><span class="tsa-spinner"></span>Loading...</div>';
// Shared listener setup — used by both success and catch paths
function attachListeners() {
content.querySelectorAll(".tsa-roi-skip").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.stopPropagation();
if (btn.dataset.owned === "1") return;
var k = btn.dataset.key;
var idx = roiSkipped.indexOf(k);
if (idx >= 0) roiSkipped.splice(idx, 1);
else roiSkipped.push(k);
lsSet("tsa_roi_skipped", JSON.stringify(roiSkipped));
showROIPlanner(ownedMap, raw);
});
});
content.querySelectorAll(".tsa-roi-buyrow").forEach(function(row) {
row.addEventListener("click", function(e) {
if (e.target.closest(".tsa-roi-skip")) return;
var sym = row.dataset.buySym;
var shares = parseInt(row.dataset.buyShares, 10);
var tier = row.dataset.buyTier;
var state = row.dataset.buyState || "0";
if (state === "0") {
row.dataset.buyState = "1";
row.style.background = "rgba(76,145,255,0.13)";
row.style.borderLeft = "2px solid #4c91ff";
var infoCol = row.querySelector("div");
if (infoCol) {
infoCol.innerHTML =
'<span style="font-size:10px;font-weight:700;color:#4c91ff;font-family:JetBrains Mono,monospace">▲ Tap again to confirm</span>' +
'<span style="font-size:9px;color:#4c91ff;font-family:JetBrains Mono,monospace">' + shares.toLocaleString("en-US") + ' shares · ' + tier + '</span>';
}
setTimeout(function() {
if (row.dataset.buyState === "1" && roiPlannerActive) {
row.dataset.buyState = "0";
showROIPlanner(ownedMap, raw);
}
}, 4000);
} else {
row.dataset.buyState = "0";
qtBuildMaps();
qtPostTrade(sym, shares, "buyShares", "Bought " + shares.toLocaleString("en-US") + " " + sym + " (" + tier + ")");
showROIPlanner(ownedMap, raw);
}
});
});
}
function renderAndAttach(cashBalance, armoryFunds) {
fetchAllItemPrices(function() {
content.innerHTML = renderROIPlanner(ownedMap, raw, cashBalance, armoryFunds);
attachListeners();
var isDarkFooter = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
var footerDivider = isDarkFooter ? "#1a1a2e" : "#eee";
var footerBg = isDarkFooter ? "#0f0f1a" : "#ffffff";
content.insertAdjacentHTML("beforeend",
'<div style="padding:7px 14px;display:flex;justify-content:space-between;align-items:center;border-top:1px solid ' + footerDivider + ';background:' + footerBg + '">' +
'<span style="font-size:9px;color:#555">✕ skip · ↩ unskip</span>' +
'<span style="font-size:9px;color:#555;font-family:monospace">Updated ' + new Date().toLocaleTimeString("en-GB") + '</span>' +
'</div>'
);
});
}
var key = getTornKey();
Promise.all([
fetchJSON("https://api.torn.com/user/?selections=basic,money&key=" + key).catch(function() { return null; }),
fetchJSON("https://api.torn.com/faction/?selections=donations&key=" + key).catch(function() { return null; })
]).then(function(results) {
var cashBalance = 0;
var armoryFunds = 0;
var userData = results[0];
var factionData = results[1];
if (userData && !userData.error) {
cashBalance = userData.money_onhand || 0;
var playerId = userData.player_id;
if (playerId && factionData && factionData.donations && factionData.donations[playerId]) {
armoryFunds = factionData.donations[playerId].money_balance || 0;
}
}
renderAndAttach(cashBalance, armoryFunds);
}).catch(function() {
renderAndAttach(0, 0);
});
}
function injectStyles() {
var el = document.createElement("style");
el.textContent = STYLES;
document.head.appendChild(el);
}
function fetchJSON(url, retries) {
if (retries === undefined) retries = 3;
return new Promise(function(resolve, reject) {
var attempt = function(n) {
GM_xmlhttpRequest({
method: "GET", url: url,
onload: function(r) {
try { resolve(JSON.parse(r.responseText)); }
catch (e) {
if (n > 1) { setTimeout(function() { attempt(n - 1); }, 2000); }
else reject(new Error("Parse error: " + r.responseText.substring(0, 80)));
}
},
onerror: function() {
if (n > 1) { setTimeout(function() { attempt(n - 1); }, 2000); }
else reject(new Error("Network error after 3 attempts: " + url));
}
});
};
attempt(retries);
});
}
function buildOwnedMap(tornData) {
var owned = {};
if (!tornData || !tornData.stocks) return owned;
Object.keys(tornData.stocks).forEach(function(id) {
var s = tornData.stocks[id];
var acronym = STOCK_ID_MAP[parseInt(id, 10)];
if (!acronym) return;
var transactions = s.transactions ? Object.keys(s.transactions).map(function(k) { return s.transactions[k]; }) : [];
if (transactions.length === 0) return;
// Total shares from ALL transactions regardless of bought_price
var totalShares = 0, earliestTime = Infinity;
transactions.forEach(function(t) {
totalShares += t.shares || 0;
if (t.time_bought && t.time_bought < earliestTime) earliestTime = t.time_bought;
});
// avg_price: only transactions with bought_price > 0
// Torn API returns bought_price=0 for old/benefit-era purchases — skip those
var validCostShares = 0, totalCost = 0;
transactions.forEach(function(t) {
if (t.bought_price && t.bought_price > 0) {
validCostShares += t.shares || 0;
totalCost += (t.shares || 0) * t.bought_price;
}
});
var avg_price = validCostShares > 0 ? totalCost / validCostShares : 0;
// Use Torn API's increment field — it is the active/confirmed tier
var apiIncrement = (s.dividend && s.dividend.increment) || 0;
// benefit_shares and swing_shares will be calculated by enrichOwnedMap
// after live prices are available — store raw data only for now
owned[acronym] = {
shares: totalShares,
swing_shares: 0, // recalculated in enrichOwnedMap
benefit_shares: 0, // recalculated in enrichOwnedMap
avg_price: avg_price,
time_bought: earliestTime === Infinity ? null : earliestTime,
has_dividend: apiIncrement > 0,
has_swing: false, // recalculated in enrichOwnedMap
dividend_progress: (s.dividend && s.dividend.progress) || 0,
dividend_frequency: (s.dividend && s.dividend.frequency) || 0,
dividend_increment: apiIncrement,
dividend_next: 0, // not exposed by API — calculated from progress/frequency
transactions: transactions.sort(function(a, b) { return b.time_bought - a.time_bought; })
};
});
return owned;
}
// Enrich ownedMap with correct benefit_shares and swing_shares
// Uses BENEFIT_REQ (fixed share counts per BB — Torn game mechanic, never changes)
// and dividend.increment from API
// Formula: benefit_shares = BENEFIT_REQ[sym] × (2^increment - 1)
// swing_shares = total_shares - benefit_shares
function enrichOwnedMap(ownedMap, raw) {
Object.keys(ownedMap).forEach(function(sym) {
var o = ownedMap[sym];
var increment = o.dividend_increment || 0;
var totalShares = o.shares || 0;
if (increment <= 0) {
o.benefit_shares = 0;
o.swing_shares = totalShares;
o.has_dividend = false;
o.has_swing = totalShares > 0;
return;
}
var req = BENEFIT_REQ[sym] || 0;
// If user has bought enough shares for a higher tier (pending next dividend day),
// treat those extra shares as BB rather than swing.
var effectiveIncrement = increment;
if (req > 0) {
while (totalShares >= (Math.pow(2, effectiveIncrement + 1) - 1) * req) {
effectiveIncrement++;
}
}
var benefitShares = req > 0 ? (Math.pow(2, effectiveIncrement) - 1) * req : 0;
benefitShares = Math.min(benefitShares, totalShares);
var swingShares = Math.max(0, totalShares - benefitShares);
o.benefit_shares = benefitShares;
o.swing_shares = swingShares;
o.has_dividend = benefitShares > 0;
o.has_swing = swingShares > 0;
});
return ownedMap;
}
function mergeIntervals(calls) {
var merged = {};
calls.forEach(function(call) {
var stocks = call.data || call;
if (!Array.isArray(stocks)) return;
stocks.forEach(function(s) {
if (!merged[s.stock]) {
merged[s.stock] = Object.assign({}, s, {interval: {}});
}
Object.assign(merged[s.stock].interval, s.interval || {});
if (s.price) merged[s.stock].price = s.price;
if (s.investors) merged[s.stock].investors = s.investors;
});
});
return Object.values(merged);
}
function calcScore(stock, raw, ownedMap, priceHistory) {
var s = stock.toUpperCase();
var r = raw ? raw.find(function(x) { return x.stock === s; }) : null;
if (!r) return null;
var owned = ownedMap[s];
var isInBenefitCategory = owned && owned.has_dividend && owned.benefit_shares > 0;
var p_live = parseFloat(r.price) || 0;
// Extract all intervals
var p_m30 = parseFloat((r.interval && r.interval.m30 && r.interval.m30.price)) || 0;
var p_h1 = parseFloat((r.interval && r.interval.h1 && r.interval.h1.price)) || 0;
var p_h2 = parseFloat((r.interval && r.interval.h2 && r.interval.h2.price)) || 0;
var p_h3 = parseFloat((r.interval && r.interval.h3 && r.interval.h3.price)) || 0;
var p_h4 = parseFloat((r.interval && r.interval.h4 && r.interval.h4.price)) || 0;
var p_h6 = parseFloat((r.interval && r.interval.h6 && r.interval.h6.price)) || 0;
var p_h8 = parseFloat((r.interval && r.interval.h8 && r.interval.h8.price)) || 0;
var p_h12 = parseFloat((r.interval && r.interval.h12 && r.interval.h12.price)) || 0;
var p_h16 = parseFloat((r.interval && r.interval.h16 && r.interval.h16.price)) || 0;
var p_h20 = parseFloat((r.interval && r.interval.h20 && r.interval.h20.price)) || 0;
var p_d1 = parseFloat((r.interval && r.interval.d1 && r.interval.d1.price)) || 0;
var p_d2 = parseFloat((r.interval && r.interval.d2 && r.interval.d2.price)) || 0;
var p_d3 = parseFloat((r.interval && r.interval.d3 && r.interval.d3.price)) || 0;
var p_d4 = parseFloat((r.interval && r.interval.d4 && r.interval.d4.price)) || 0;
var p_d5 = parseFloat((r.interval && r.interval.d5 && r.interval.d5.price)) || 0;
var p_d7 = parseFloat((r.interval && r.interval.d7 && r.interval.d7.price)) || 0;
var p_w1 = parseFloat((r.interval && r.interval.w1 && r.interval.w1.price)) || 0;
var score = 0;
var reasons = [];
var scoreBreakdown = { drop: 0, position: 0, reversal: 0, macd: 0, rsi: 0 };
// ── HARD FILTER: must be below weekly level ───────────────────────
// Live must be below w1 — mandatory, no score if fails
if (p_w1 > 0 && p_live >= p_w1) {
return {
symbol: s, score: 0, signal: "WAIT", sellSignal: null,
scoreBreakdown: { drop: 0, position: 0, reversal: 0 },
owned: !!owned, alreadyRallied: false, priceAboveWeek: true,
has_swing: (owned && owned.has_swing) || false,
has_benefit: (owned && owned.has_dividend) || false,
hasDividend: (owned && owned.has_dividend) || false,
dividendProgress: (owned && owned.dividend_progress) || 0,
dividendFrequency: (owned && owned.dividend_frequency) || 0,
p_live, reasons: "Above weekly level",
netProfitPct: (owned && owned.avg_price > 0) ? ((p_live * 0.999 - owned.avg_price) / owned.avg_price * 100) : null,
hoursHeld: (owned && owned.time_bought) ? ((Date.now()/1000 - owned.time_bought)/3600).toFixed(0) : null,
shares: (owned && owned.shares) || 0,
avg_price: (owned && owned.avg_price) || 0,
transactions: (owned && owned.transactions) || []
};
}
// ── 1. DROP FROM WEEKLY PEAK (max 60p) ───────────────────────────
// How far has price dropped from its weekly high?
// Drop threshold is dynamic: based on stock's average daily volatility (1x)
var weekPrices = [p_d1,p_d2,p_d3,p_d4,p_d5,p_d7,p_w1].filter(function(x){ return x > 0; });
var dropFromWeekPeak = 0;
// Calculate dynamic drop threshold from localStorage history (avg hourly % change × 24)
var dynamicDropThreshold = -1.0; // fallback
var histPrices = priceHistory && priceHistory[s.toUpperCase()];
if (histPrices && histPrices.length >= 48) {
var sortedHist = histPrices.slice().sort(function(a,b){ return a.ts - b.ts; });
var hPrices = sortedHist.map(function(e){ return e.price; });
var changes = [];
for (var hi = 1; hi < hPrices.length; hi++) {
if (hPrices[hi-1] > 0) changes.push(Math.abs(hPrices[hi] - hPrices[hi-1]) / hPrices[hi-1] * 100);
}
if (changes.length > 0) {
var avgHourlyVol = changes.reduce(function(a,b){ return a+b; }, 0) / changes.length;
dynamicDropThreshold = -Math.max(0.5, avgHourlyVol * 24);
}
}
if (weekPrices.length > 0) {
var weekPeak = Math.max.apply(null, weekPrices);
dropFromWeekPeak = ((p_live - weekPeak) / weekPeak) * 100;
// Score relative to dynamic threshold
var dt = dynamicDropThreshold; // negative value e.g. -1.2
if (dropFromWeekPeak <= dt * 4.0) { score += 60; scoreBreakdown.drop = 60; reasons.push("Drop " + dropFromWeekPeak.toFixed(1) + "%"); }
else if (dropFromWeekPeak <= dt * 2.5) { score += 50; scoreBreakdown.drop = 50; reasons.push("Drop " + dropFromWeekPeak.toFixed(1) + "%"); }
else if (dropFromWeekPeak <= dt * 1.5) { score += 40; scoreBreakdown.drop = 40; reasons.push("Drop " + dropFromWeekPeak.toFixed(1) + "%"); }
else if (dropFromWeekPeak <= dt * 1.0) { score += 30; scoreBreakdown.drop = 30; reasons.push("Drop " + dropFromWeekPeak.toFixed(1) + "%"); }
else if (dropFromWeekPeak <= dt * 0.5) { score += 15; scoreBreakdown.drop = 15; reasons.push("Drop " + dropFromWeekPeak.toFixed(1) + "%"); }
else { reasons.push("Drop " + dropFromWeekPeak.toFixed(1) + "%"); }
}
// ── 2. NEAR SHORT-TERM BOTTOM (max 35p) ──────────────────────────
// Is live near the bottom of its recent (h1-d2) range?
var shortPrices = [p_h1,p_h2,p_h3,p_h4,p_h6,p_h8,p_h12,p_h16,p_h20,p_d1,p_d2].filter(function(x){ return x > 0; });
if (shortPrices.length >= 2) {
var shortLow = Math.min.apply(null, shortPrices);
var shortHigh = Math.max.apply(null, shortPrices);
var shortRange = shortHigh - shortLow;
if (shortRange > 0) {
var posInShort = Math.max(0, ((p_live - shortLow) / shortRange) * 100);
if (posInShort <= 10) { score += 35; scoreBreakdown.position = 35; reasons.push("Near bottom " + posInShort.toFixed(0) + "%"); }
else if (posInShort <= 25) { score += 25; scoreBreakdown.position = 25; reasons.push("Low pos " + posInShort.toFixed(0) + "%"); }
else if (posInShort <= 40) { score += 15; scoreBreakdown.position = 15; reasons.push("Pos " + posInShort.toFixed(0) + "%"); }
else if (posInShort <= 55) { score += 5; scoreBreakdown.position = 5; reasons.push("Pos " + posInShort.toFixed(0) + "%"); }
else { reasons.push("High pos " + posInShort.toFixed(0) + "%"); }
}
}
// ── 3. SHORT-TERM PRICE MOVEMENT (max 40p) ───────────────────────
// Is price actively rising right now? m30 vs h1 vs h2
var m30Valid = p_m30 > 0, h1Valid = p_h1 > 0, h2Valid = p_h2 > 0;
if (m30Valid && h1Valid && h2Valid) {
var rising_m30_h1 = p_m30 > p_h1;
var rising_h1_h2 = p_h1 > p_h2;
if (rising_m30_h1 && rising_h1_h2) {
score += 40; scoreBreakdown.reversal = 40; reasons.push("Active rise");
} else if (rising_m30_h1) {
score += 20; scoreBreakdown.reversal = 20; reasons.push("Rising +" + ((p_m30 - p_h1) / p_h1 * 100).toFixed(2) + "%");
} else if (Math.abs(p_m30 - p_h1) / p_h1 * 100 <= 0.1) {
score += 8; scoreBreakdown.reversal = 8; reasons.push("Stabilizing");
} else {
reasons.push("Still falling " + ((p_m30 - p_h1) / p_h1 * 100).toFixed(2) + "%");
}
} else if (m30Valid && h1Valid) {
if (p_m30 > p_h1) {
score += 20; scoreBreakdown.reversal = 20; reasons.push("Rising");
} else {
reasons.push("Falling");
}
}
// ── 4. MACD (max 25p) ────────────────────────────────────────────
var macdResult = calcMACD(s, priceHistory);
if (macdResult !== null) {
if (macdResult.crossover) {
score += 25; scoreBreakdown.macd = 25; reasons.push("MACD crossover");
} else if (macdResult.macd > macdResult.signal) {
score += 12; scoreBreakdown.macd = 12; reasons.push("MACD bullish");
} else {
reasons.push("MACD bearish");
}
}
// ── 5. RSI (max 20p) — Torn-calibrated via percentile ───────────
// Uses the stock's own RSI history to define "oversold" for that specific
// stock, instead of generic 30/70 real-market thresholds.
var rsiCtx = calcRSIContext(s, priceHistory);
var rsi = rsiCtx ? rsiCtx.rsi : null;
var rsiPercentile = rsiCtx ? rsiCtx.percentile : null;
if (rsiPercentile !== null) {
// Percentile-based: how low is current RSI vs this stock's own RSI history
if (rsiPercentile <= 10) { score += 20; scoreBreakdown.rsi = 20; reasons.push("RSI " + rsi.toFixed(0) + " (btm 10%)"); }
else if (rsiPercentile <= 25) { score += 12; scoreBreakdown.rsi = 12; reasons.push("RSI " + rsi.toFixed(0) + " (btm 25%)"); }
else if (rsiPercentile <= 40) { score += 6; scoreBreakdown.rsi = 6; reasons.push("RSI " + rsi.toFixed(0)); }
else { reasons.push("RSI " + rsi.toFixed(0)); }
} else if (rsi !== null) {
// Fallback: absolute RSI with Torn-adjusted thresholds (conservative — Torn
// stocks lack the liquidity floor that real markets provide)
if (rsi <= 35) { score += 15; scoreBreakdown.rsi = 15; reasons.push("RSI " + rsi.toFixed(0)); }
else if (rsi <= 45) { score += 7; scoreBreakdown.rsi = 7; reasons.push("RSI " + rsi.toFixed(0)); }
else { reasons.push("RSI " + rsi.toFixed(0)); }
}
// ── HARD FILTERS ─────────────────────────────────────────────────
var recentPrices = [p_h1,p_h2,p_h4,p_h8,p_h12,p_d1].filter(function(x){ return x > 0; });
var recentLow = recentPrices.length > 0 ? Math.min.apply(null, recentPrices) : 0;
// Already rallied if price has recovered more than half of the weekly drop
var rallyPct = recentLow > 0 && p_live > recentLow ? ((p_live - recentLow) / recentLow * 100) : 0;
var rallyThreshold = dropFromWeekPeak < 0 ? Math.abs(dropFromWeekPeak) / 2 : 0.3;
var alreadyRallied = rallyPct > rallyThreshold;
// RSI overbought: use top-85th percentile when available, else absolute >65
var rsiOverbought = rsiPercentile !== null ? rsiPercentile >= 85 : (rsi !== null && rsi > 65);
// SIGNAL — requires MACD crossover (25p) AND score >= 100 for STRONG BUY
var signal;
if (score >= 100 && scoreBreakdown.macd === 25) signal = "STRONG BUY";
else if (score >= 75) signal = "BUY";
else if (score >= 45) signal = "CONSIDER";
else signal = "WAIT";
// SELL LOGIC
var sellSignal = null, netProfitPct = null, hoursHeld = null;
var isBenefitBlock = isInBenefitCategory;
// Calculate profit % for ALL owned stocks — include 0.1% sell fee for accurate P/L
if (owned && owned.avg_price > 0) {
netProfitPct = ((p_live * 0.999 - owned.avg_price) / owned.avg_price * 100);
hoursHeld = owned.time_bought
? ((Date.now() / 1000 - owned.time_bought) / 3600).toFixed(0)
: null;
}
// Sell signal ONLY for swing trades
if (owned && owned.avg_price > 0 && !isBenefitBlock) {
var profitTarget = getProfitTarget();
var stopLossThreshold = getStopLoss();
if (netProfitPct >= profitTarget) sellSignal = "PROFIT";
else if (netProfitPct <= -stopLossThreshold) sellSignal = "STOP LOSS";
}
return {
symbol: s, score, signal, sellSignal,
scoreBreakdown: scoreBreakdown,
owned: !!owned,
alreadyRallied: alreadyRallied,
rsiOverbought: rsiOverbought,
priceAboveWeek: p_w1 > 0 && p_live > p_w1,
has_swing: (owned && owned.has_swing) || false,
has_benefit: (owned && owned.has_dividend) || false,
hasDividend: (owned && owned.has_dividend) || false,
dividendProgress: (owned && owned.dividend_progress) || 0,
dividendFrequency: (owned && owned.dividend_frequency) || 0,
p_live, reasons: reasons.join(" | "), netProfitPct, hoursHeld,
shares: (owned && owned.shares) || 0,
avg_price: (owned && owned.avg_price) || 0,
transactions: (owned && owned.transactions) || []
};
}
function getProfitTarget() {
return parseFloat(lsGet("tsa_profit_target", "0.3"));
}
function getStopLoss() {
return parseFloat(lsGet("tsa_stop_loss", "1.0"));
}
function escHtml(s) {
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
}
function showToast(msg, type) {
var colors = {
success: { bg:"rgba(20,160,70,0.93)", border:"#4cff91", icon:"✓" },
error: { bg:"rgba(170,20,50,0.93)", border:"#ff4c6a", icon:"✕" },
warn: { bg:"rgba(150,110,0,0.93)", border:"#ffc107", icon:"!" },
info: { bg:"rgba(40,70,140,0.93)", border:"#7a9fd4", icon:"i" }
};
var c = colors[type] || colors.info;
var toast = document.createElement("div");
toast.style.cssText = "position:fixed;bottom:16px;left:16px;z-index:2147483648;" +
"max-width:280px;padding:10px 14px;border-radius:10px;border-left:3px solid " + c.border + ";" +
"background:" + c.bg + ";color:#fff;font-family:JetBrains Mono,monospace;font-size:12px;" +
"font-weight:600;box-shadow:0 4px 16px rgba(0,0,0,0.4);display:flex;align-items:center;" +
"gap:8px;animation:tsaToastIn 0.2s ease";
toast.innerHTML = "<span style='font-size:14px;flex-shrink:0'>" + c.icon + "</span><span>" + escHtml(msg) + "</span>";
document.body.appendChild(toast);
setTimeout(function() {
toast.style.opacity = "0";
toast.style.transition = "opacity 0.3s";
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 320);
}, 3000);
}
function updateCountdownLabel() {
var el = document.getElementById("tsa-countdown");
if (!el) return;
if (!autoRefreshEndTime) { el.textContent = ""; return; }
var rem = Math.max(0, autoRefreshEndTime - Date.now());
var m = Math.floor(rem / 60000);
var s = Math.floor((rem % 60000) / 1000);
el.textContent = " · " + m + "m " + (s < 10 ? "0" : "") + s + "s";
}
var API_ERRORS = {
"Incorrect key": "Invalid API key — click 🔑 to update it.",
"Access level": "API key is missing required permissions — click 🔑 to create a new key.",
"Too many": "Too many requests — wait a moment and try again.",
"Player not found": "Player profile not found. Check the API key."
};
function friendlyApiError(msg) {
var keys = Object.keys(API_ERRORS);
for (var i = 0; i < keys.length; i++) {
if (msg && msg.indexOf(keys[i]) !== -1) return API_ERRORS[keys[i]];
}
return msg;
}
function getTsaStorageSize() {
var total = 0;
try {
Object.keys(localStorage).forEach(function(key) {
if (key.indexOf("tsa") === 0 || key.indexOf("qt_") === 0) {
total += (lsGet(key) || "").length;
}
});
} catch(e) {}
if (total < 1024) return total + " B";
if (total < 1024 * 1024) return (total / 1024).toFixed(1) + " KB";
return (total / (1024 * 1024)).toFixed(2) + " MB";
}
function getProfitSwingOnly() {
return lsGet("tsa_profit_swing_only", "true") !== "false";
}
function getShowWatch() {
return lsGet("tsa_show_watch", "true") !== "false";
}
function getTop5MinScore() {
var v = parseInt(lsGet("tsa_top5_min_score", "35"), 10);
return isNaN(v) ? 35 : v;
}
function getRequirePositiveInvestors() {
return lsGet("tsa_require_positive_investors", "false") === "true";
}
function getShowRealized() {
return lsGet("tsa_show_realized", "false") === "true";
}
function getOverlayPosition() {
return lsGet("tsa_overlay_position", "bottom-right");
}
function applyOverlayPosition(pos) {
var p = {
"bottom-right": { btn: { bottom:"80px", top:"auto", right:"16px", left:"auto" }, overlay: { bottom:"130px", top:"auto", right:"16px", left:"auto" } },
"bottom-left": { btn: { bottom:"80px", top:"auto", right:"auto", left:"16px" }, overlay: { bottom:"130px", top:"auto", right:"auto", left:"16px" } },
"top-right": { btn: { bottom:"auto", top:"16px", right:"16px", left:"auto" }, overlay: { bottom:"auto", top:"60px", right:"16px", left:"auto" } },
"top-left": { btn: { bottom:"auto", top:"16px", right:"auto", left:"16px" }, overlay: { bottom:"auto", top:"60px", right:"auto", left:"16px" } }
}[pos] || { btn: { bottom:"80px", top:"auto", right:"16px", left:"auto" }, overlay: { bottom:"130px", top:"auto", right:"16px", left:"auto" } };
var btn = document.getElementById("tsa-btn");
var ov = document.getElementById("tsa-overlay");
if (btn) { btn.style.bottom = p.btn.bottom; btn.style.top = p.btn.top; btn.style.right = p.btn.right; btn.style.left = p.btn.left; }
if (ov) { ov.style.bottom = p.overlay.bottom; ov.style.top = p.overlay.top; ov.style.right = p.overlay.right; ov.style.left = p.overlay.left; }
}
function getRealizedDays() {
return parseInt(lsGet("tsa_realized_days", "7"), 10);
}
function getRealizedEvents() {
try { return JSON.parse(localStorage.getItem("tsa_realized_events") || "[]"); }
catch(e) { return []; }
}
function getRealizedTotal() {
var days = getRealizedDays();
var cutoff = (Date.now() / 1000) - (days * 86400);
return getRealizedEvents()
.filter(function(e) { return e.ts >= cutoff; })
.reduce(function(sum, e) { return sum + (e.profit || 0); }, 0);
}
function getRealizedByStock() {
var days = getRealizedDays();
var cutoff = (Date.now() / 1000) - (days * 86400);
var byStock = {};
getRealizedEvents()
.filter(function(e) { return e.ts >= cutoff; })
.forEach(function(e) { byStock[e.sym] = (byStock[e.sym] || 0) + (e.profit || 0); });
return byStock;
}
// Remove buy intents older than 24 hours to avoid stale slippage warnings
function cleanOldIntents() {
var cutoff = Math.floor(Date.now() / 1000) - 86400;
try {
Object.keys(localStorage).forEach(function(key) {
if (key.indexOf("qt_intent_") !== 0) return;
var v = JSON.parse(localStorage.getItem(key) || "null");
if (!v || v.ts < cutoff) localStorage.removeItem(key);
});
} catch(e) {}
}
function loadData() {
cleanOldIntents();
var content = document.getElementById("tsa-content");
var isDarkMode = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
content.style.background = isDarkMode ? "#0f0f1a" : "#ffffff";
// Check for API key
var key = getTornKey();
if (!key || key === "###PDA-APIKEY###") {
showKeyOnboarding(content, function() { loadData(); });
return;
}
content.innerHTML = "<div class=\"tsa-loading\">Fetching data...</div>";
Promise.all([
fetchJSON("https://api.torn.com/user/?selections=stocks&key=" + getTornKey()),
fetchJSON("https://tornsy.com/api/stocks?interval=m30,h1,h2,h3,h4"),
fetchJSON("https://tornsy.com/api/stocks?interval=h6,h8,h10,h12,h16"),
fetchJSON("https://tornsy.com/api/stocks?interval=h20,d1,d2,d3,d4"),
fetchJSON("https://tornsy.com/api/stocks?interval=d5,d6,d7,w1,h5")
]).then(function(results) {
var tornData = results[0];
var t1 = results[1];
var t2 = results[2];
var t3 = results[3];
var t4 = results[4];
if (tornData.error) { throw new Error(friendlyApiError(tornData.error.error)); }
var ownedMap = buildOwnedMap(tornData);
var ownedSymbols = Object.keys(ownedMap);
var raw = mergeIntervals([t1, t2, t3, t4]);
// Enrich ownedMap with correct benefit_shares/swing_shares using live prices
enrichOwnedMap(ownedMap, raw);
// Track realized profit from sold positions
if (getShowRealized()) {
try {
var prevHoldings = JSON.parse(localStorage.getItem("tsa_prev_holdings") || "{}");
var realizedEvents = getRealizedEvents();
var nowTs = Math.floor(Date.now() / 1000);
var prevSyms = Object.keys(prevHoldings);
for (var rpi = 0; rpi < prevSyms.length; rpi++) {
var rpSym = prevSyms[rpi];
var prevEntry = prevHoldings[rpSym];
if (!prevEntry) continue;
var curEntry = ownedMap[rpSym];
var prevShares = prevEntry.shares || 0;
var curShares = curEntry ? (curEntry.shares || 0) : 0;
var soldShares = prevShares - curShares;
if (soldShares <= 0) continue;
var rpRaw = raw ? raw.find(function(x) { return x.stock === rpSym; }) : null;
var liveP = rpRaw ? (parseFloat(rpRaw.price) || 0) : 0;
var costP = prevEntry.avg_price || 0;
if (liveP <= 0 || costP <= 0) continue;
var realizedProfit = (liveP * 0.999 - costP) * soldShares;
realizedEvents.push({ ts: nowTs, profit: realizedProfit, sym: rpSym, sell_price: liveP });
}
// Trim events older than 90 days
var trim90 = nowTs - 90 * 86400;
realizedEvents = realizedEvents.filter(function(e) { return e.ts >= trim90; });
localStorage.setItem("tsa_realized_events", JSON.stringify(realizedEvents));
} catch(e) {}
// Save current holdings snapshot for next comparison
var holdingsSnap = {};
Object.keys(ownedMap).forEach(function(s) {
holdingsSnap[s] = { shares: ownedMap[s].shares || 0, avg_price: ownedMap[s].avg_price || 0 };
});
lsSet("tsa_prev_holdings", JSON.stringify(holdingsSnap));
}
// Store for ROI planner
lastOwnedMap = ownedMap;
lastRaw = raw;
// Calculate best ROI recommendation for Quick Trade bar
var benefitSymsForRec = Object.keys(BENEFIT_REQ);
var recCandidates = [];
benefitSymsForRec.forEach(function(sym) {
var tierInfo = calcNextTier(sym, ownedMap, raw);
if (!tierInfo || tierInfo.sharesNeeded <= 0) return;
var payoutEntry = ROI_MAP[sym + "|T" + tierInfo.nextIncrement] || null;
if (!payoutEntry) {
// Fallback: find highest available tier for this sym
for (var ri2 = ROI_TABLE.length - 1; ri2 >= 0; ri2--) {
if (ROI_TABLE[ri2].sym === sym) { payoutEntry = ROI_TABLE[ri2]; break; }
}
}
var roi = payoutEntry ? (payoutEntry.payout / tierInfo.cost * (365 / payoutEntry.freq) * 100) : 0;
recCandidates.push({ sym: sym, tierInfo: tierInfo, cost: tierInfo.cost, roi: roi });
});
recCandidates.sort(function(a, b) { return b.roi - a.roi; });
lastBestRec = recCandidates[0] || null;
updateQtRecommendation(null);
// Check price alerts
checkAlerts(raw);
// Record live prices to history — returns saved object to avoid re-parsing
var _savedHistory = recordPrices(raw);
// Update chart with selected stock
var currentStock = lsGet("qt_last_stock", "");
if (currentStock) qtDrawChart(currentStock);
// If ROI planner is active, show it instead
if (roiPlannerActive) { showROIPlanner(ownedMap, raw); return; }
function safeParseArr(key) { try { return JSON.parse(localStorage.getItem(key) || "[]") || []; } catch(e) { return []; } }
var hiddenStocks = safeParseArr("tsa_hidden");
var cachedPriceHistory = _savedHistory || loadHistory();
var stockResults = STOCKS_LIST
.map(function(s) { return calcScore(s, raw, ownedMap, cachedPriceHistory); })
.filter(Boolean)
.sort(function(a, b) { return b.score - a.score; });
// Log buy signals to history
recordSignals(stockResults);
// Snapshot prices for trend arrows on next load
var prevPrices = {};
Object.keys(lastLoadPrices).forEach(function(k) { prevPrices[k] = lastLoadPrices[k]; });
stockResults.forEach(function(s) { if (s.p_live > 0) lastLoadPrices[s.symbol] = s.p_live; });
var top5MinScore = getTop5MinScore();
var cachedInvHistory = loadInvestorHistory();
stockResults.forEach(function(s) {
s.invDelta = getInvestorDelta(s.symbol, cachedInvHistory);
});
var top5BuyAll = stockResults.filter(function(s) {
if (s.score < top5MinScore) return false;
if (s.alreadyRallied) return false;
if (s.priceAboveWeek) return false;
if (s.rsiOverbought) return false;
if (getRequirePositiveInvestors() && (s.invDelta === null || s.invDelta <= 0)) return false;
if (!s.owned) return true;
// Owned stocks only show in top 5 if BUY or above (score >= 75)
return s.score >= 75;
});
// Pinned stocks bubble to top
top5BuyAll.sort(function(a, b) {
var ap = tsaPinned.indexOf(a.symbol) >= 0 ? 0 : 1;
var bp = tsaPinned.indexOf(b.symbol) >= 0 ? 0 : 1;
if (ap !== bp) return ap - bp;
return b.score - a.score;
});
var top5Buy = top5BuyAll.slice(0, 5);
// WATCH: all owned stocks with score 50-79 not already in top5Buy
var watchList = stockResults.filter(function(s) {
if (!s.owned) return false;
if (s.score < 45 || s.score >= 75) return false;
if (s.alreadyRallied) return false;
if (s.priceAboveWeek) return false;
if (s.rsiOverbought) return false;
return !top5Buy.some(function(b) { return b.symbol === s.symbol; });
});
var fm = function(n) {
var abs = Math.abs(n), sign = n < 0 ? "-" : "+";
if (abs >= 1e9) return sign + "$" + (abs/1e9).toFixed(1) + "B";
if (abs >= 1e6) return sign + "$" + (abs/1e6).toFixed(1) + "M";
if (abs >= 1e3) return sign + "$" + (abs/1e3).toFixed(0) + "K";
return sign + "$" + abs.toFixed(0);
};
var totalProfit = 0;
var ownedKeys = Object.keys(ownedMap);
for (var ki = 0; ki < ownedKeys.length; ki++) {
var profitSym = ownedKeys[ki];
var ownedEntry = ownedMap[profitSym];
// Filter by swing-only setting
if (getProfitSwingOnly() && (!ownedEntry.has_swing || ownedEntry.swing_shares <= 0)) continue;
var rawEntry = raw ? raw.find(function(x) { return x.stock === profitSym; }) : null;
if (!rawEntry) continue;
var livePrice = parseFloat(rawEntry.price) || 0;
if (livePrice <= 0) continue;
// For mixed stocks (benefit + swing) in swing-only mode, count only the swing portion
var isMixedStock = ownedEntry.has_dividend && ownedEntry.swing_shares > 0 && ownedEntry.benefit_shares > 0;
if (getProfitSwingOnly() && isMixedStock) {
var swingCost = ownedEntry.avg_price || 0;
if (swingCost > 0) {
totalProfit += (livePrice * 0.999 - swingCost) * ownedEntry.swing_shares;
}
} else {
// Calculate profit per transaction using bought_price if available,
// otherwise fall back to avg_price for transactions with bought_price=0
var fallbackAvg = ownedEntry.avg_price || 0;
var txProfit = 0;
var txCounted = false;
if (ownedEntry.transactions && ownedEntry.transactions.length > 0) {
ownedEntry.transactions.forEach(function(t) {
var shares = t.shares || 0;
if (shares <= 0) return;
var costPrice = (t.bought_price && t.bought_price > 0) ? t.bought_price : fallbackAvg;
if (costPrice <= 0) return;
txProfit += (livePrice * 0.999 - costPrice) * shares;
txCounted = true;
});
}
if (txCounted) totalProfit += txProfit;
}
}
// Colour palette based on dark mode
var isDark2 = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
var d = isDark2 ? {
bg:"#0f0f1a", bg2:"#0d0d18", bg3:"#1a1a2e", border:"#2a2a4a",
text:"#c8c8d8", muted:"#7a7a9a", blue:"#7a9fd4",
green:"#4cff91", red:"#ff4c6a", yellow:"#ffc107",
rowBuy:"rgba(76,255,145,0.08)", rowBuyBorder:"rgba(76,255,145,0.2)",
rowSell:"rgba(255,76,106,0.08)", rowSellBorder:"rgba(255,76,106,0.2)",
rowBenefit:"rgba(160,160,255,0.05)", rowBenefitBorder:"rgba(160,160,255,0.1)",
divider:"#1a1a2e", txBg:"#0d0d18", txBorder:"#2a2a4a",
moveBg:"rgba(255,193,7,0.1)", moveBorder:"rgba(255,193,7,0.3)", moveColor:"#ffc107",
moveBg2:"rgba(122,159,212,0.1)", moveBorder2:"rgba(122,159,212,0.3)", moveColor2:"#7a9fd4",
mono:"JetBrains Mono,monospace"
} : {
bg:"#ffffff", bg2:"#f7f9fc", bg3:"#f0f4ff", border:"#eee",
text:"#222", muted:"#888", blue:"#4a6fa5",
green:"#1a8a45", red:"#cc2222", yellow:"#856404",
rowBuy:"#edfaf3", rowBuyBorder:"#a8e6c0",
rowSell:"#fff0f0", rowSellBorder:"#ffb3b3",
rowBenefit:"#f0f4ff", rowBenefitBorder:"#c0d0ff",
divider:"#eee", txBg:"#f9f9f9", txBorder:"#e0e0e0",
moveBg:"#fff3cd", moveBorder:"#ffc107", moveColor:"#856404",
moveBg2:"#f0f4ff", moveBorder2:"#c0d0ff", moveColor2:"#4a6fa5",
mono:"Arial,sans-serif"
};
var ms = "font-family:" + d.mono + ";";
var html = "<div style=\"display:grid;grid-template-columns:repeat(3,1fr);gap:8px;padding:12px 14px;border-bottom:1px solid " + d.border + ";background:" + d.bg + "\">" +
"<div style=\"background:" + d.bg2 + ";border-radius:8px;padding:8px;text-align:center;border:1px solid " + d.border + "\">" +
"<div style=\"font-size:10px;color:" + d.muted + ";margin-bottom:4px\">Analyzed</div>" +
"<div style=\"font-size:16px;font-weight:bold;color:" + d.text + ";" + ms + "\">" + stockResults.length + "</div></div>" +
"<div style=\"background:" + d.bg2 + ";border-radius:8px;padding:8px;text-align:center;border:1px solid " + d.border + "\">" +
"<div style=\"font-size:10px;color:" + d.muted + ";margin-bottom:4px\">You own</div>" +
"<div style=\"font-size:16px;font-weight:bold;color:" + d.text + ";" + ms + "\">" + ownedSymbols.length + "</div></div>" +
(function() {
var showRealized = getShowRealized();
var realizedTotal = showRealized ? getRealizedTotal() : 0;
var realDays = getRealizedDays();
if (showRealized) {
var realExpanded = lsGet("tsa_realized_expanded", "false") === "true";
var byStock = realExpanded ? getRealizedByStock() : {};
var stockKeys = Object.keys(byStock).sort(function(a, b) { return Math.abs(byStock[b]) - Math.abs(byStock[a]); });
var detailHtml = stockKeys.map(function(sym) {
var p = byStock[sym];
return "<div style=\"display:flex;justify-content:space-between;padding:1px 0;\">" +
"<span style=\"font-size:9px;color:" + d.muted + "\">" + sym + "</span>" +
"<span style=\"font-size:9px;font-weight:bold;color:" + (p >= 0 ? d.green : d.red) + ";" + ms + "\">" + fm(p) + "</span>" +
"</div>";
}).join("");
return "<div style=\"background:" + d.bg2 + ";border-radius:8px;padding:8px;border:1px solid " + d.border + "\">" +
"<div style=\"display:flex;justify-content:space-between;align-items:baseline;margin-bottom:3px\">" +
"<div style=\"font-size:9px;color:" + d.muted + "\">Open P/L</div>" +
"<div style=\"font-size:13px;font-weight:bold;color:" + (totalProfit >= 0 ? d.green : d.red) + ";" + ms + "\">" + fm(totalProfit) + "</div>" +
"</div>" +
"<div id=\"tsa-realized-row\" style=\"display:flex;justify-content:space-between;align-items:baseline;cursor:pointer;\">" +
"<div style=\"font-size:9px;color:" + d.muted + "\">Real. " + realDays + "d " + (realExpanded ? "▼" : "►") + "</div>" +
"<div style=\"font-size:13px;font-weight:bold;color:" + (realizedTotal >= 0 ? d.green : d.red) + ";" + ms + "\">" + fm(realizedTotal) + "</div>" +
"</div>" +
(realExpanded && stockKeys.length > 0 ? "<div style=\"margin-top:4px;border-top:1px solid " + d.border + ";padding-top:4px\">" + detailHtml + "</div>" : "") +
"</div>";
} else {
return "<div style=\"background:" + d.bg2 + ";border-radius:8px;padding:8px;text-align:center;border:1px solid " + d.border + "\">" +
"<div style=\"font-size:10px;color:" + d.muted + ";margin-bottom:4px\">Trading profit</div>" +
"<div style=\"font-size:16px;font-weight:bold;color:" + (totalProfit >= 0 ? d.green : d.red) + ";" + ms + "\">" + fm(totalProfit) + "</div></div>";
}
})() +
"</div>";
// Builds a "gap to next signal" hint line for the score breakdown panel.
// Shows how many points are missing and which indicators have the most
// unrealized potential to get there.
function buildGapHtml(bd, score, signal) {
var SIGNAL_THRESHOLDS = { "WAIT": 45, "CONSIDER": 75, "BUY": 100 };
var nextLabel = signal === "WAIT" ? "CONSIDER" : signal === "CONSIDER" ? "BUY" : signal === "BUY" ? "STRONG BUY" : null;
if (!nextLabel) return ""; // already STRONG BUY
var nextThreshold = SIGNAL_THRESHOLDS[signal];
var gap = nextThreshold - score;
if (gap <= 0) return "";
// Indicators sorted by unrealized potential (max - current)
var indicators = [
{ key: "drop", label: "stronger drop", max: 60, pts: bd.drop || 0 },
{ key: "reversal", label: "active price rise", max: 40, pts: bd.reversal || 0 },
{ key: "position", label: "lower range position",max: 35, pts: bd.position || 0 },
{ key: "macd", label: "MACD crossover", max: 25, pts: bd.macd || 0 },
{ key: "rsi", label: "lower RSI", max: 20, pts: bd.rsi || 0 },
];
indicators.sort(function(a, b) { return (b.max - b.pts) - (a.max - a.pts); });
// Only mention indicators that can actually contribute meaningful points
var useful = indicators.filter(function(i) { return (i.max - i.pts) >= 5; }).slice(0, 2);
var needsStr = useful.length
? " — needs " + useful.map(function(i) { return i.label; }).join(" OR ")
: "";
var gapColor = gap <= 15 ? d.green : gap <= 30 ? d.yellow : d.muted;
return "<div style=\"margin-bottom:4px;padding:5px 8px;border-radius:6px;" +
"background:" + (isDark2 ? "rgba(122,159,212,0.07)" : "#f0f4ff") + ";" +
"border:1px solid " + (isDark2 ? "rgba(122,159,212,0.15)" : "#c0d0ff") + "\">" +
"<span style=\"font-size:10px;color:" + d.muted + "\">Gap to " + nextLabel + ": </span>" +
"<span style=\"font-size:10px;font-weight:bold;color:" + gapColor + ";" + ms + "\">" + gap + "p</span>" +
"<span style=\"font-size:10px;color:" + d.muted + "\">" + needsStr + "</span>" +
"</div>";
}
if (top5Buy.length > 0) {
html += "<div style=\"padding:10px 14px 6px;background:" + d.bg + "\">" +
"<div style=\"font-size:10px;letter-spacing:0.12em;color:" + d.muted + ";text-transform:uppercase;margin-bottom:8px;font-weight:bold\">" +
"Top " + top5Buy.length + " buy</div>";
top5Buy.forEach(function(s) {
var breakdownId = "tsa-breakdown-" + s.symbol;
var bd = s.scoreBreakdown || {};
var invDelta = s.invDelta;
var invHtml = "";
if (invDelta !== null) {
var invColor = invDelta > 0 ? d.green : invDelta < 0 ? d.red : d.muted;
var invSign = invDelta > 0 ? "+" : "";
invHtml = " <span style=\"font-size:9px;color:" + invColor + ";font-weight:bold\">" + invSign + invDelta.toLocaleString("en-US") + " inv 24h</span>";
}
var bdRows = [
{ label: "Drop from weekly peak", pts: bd.drop || 0, max: 60 },
{ label: "Position in range", pts: bd.position || 0, max: 35 },
{ label: "Short-term rise", pts: bd.reversal || 0, max: 40 },
{ label: "MACD", pts: bd.macd || 0, max: 25 },
{ label: "RSI (Torn-calibrated)", pts: bd.rsi || 0, max: 20 },
];
var bdHtml = bdRows.map(function(row) {
var barW = row.max > 0 ? Math.round((row.pts / row.max) * 100) : 0;
var barColor = row.pts >= row.max * 0.75 ? d.green : row.pts >= row.max * 0.4 ? d.yellow : d.muted;
var maxBadge = row.pts === row.max ? " <span style=\"color:" + d.green + ";font-size:9px\">★ MAX</span>" : "";
return "<div style=\"margin-bottom:6px\">" +
"<div style=\"display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px\">" +
"<span style=\"color:" + d.muted + "\">" + row.label + "</span>" +
"<span style=\"color:" + d.text + ";font-weight:bold;" + ms + "\">" + row.pts + " / " + row.max + "p" + maxBadge + "</span>" +
"</div>" +
"<div style=\"height:4px;background:" + d.border + ";border-radius:2px\">" +
"<div style=\"height:4px;width:" + barW + "%;background:" + barColor + ";border-radius:2px\"></div>" +
"</div></div>";
}).join("");
var signalColor = s.signal === "STRONG BUY" ? "#FFD700" : s.signal === "BUY" ? d.green : s.signal === "CONSIDER" ? "#FFA500" : d.muted;
// Color based on ownership: unowned=green, owned swing=amber, owned benefit=blue
var symColor = !s.owned ? d.green : s.has_benefit ? d.blue : d.yellow;
var rowBg = !s.owned ? d.rowBuy : s.has_benefit ? d.rowBenefit : (isDark2 ? "rgba(255,193,7,0.07)" : "#fffbeb");
var rowBorder = !s.owned ? d.rowBuyBorder : s.has_benefit ? d.rowBenefitBorder : (isDark2 ? "rgba(255,193,7,0.25)" : "#fde68a");
// Trend arrow vs previous load
var prevP = prevPrices[s.symbol];
var trendHtml = "";
if (prevP && prevP > 0 && s.p_live > 0) {
var diff = ((s.p_live - prevP) / prevP * 100);
if (Math.abs(diff) >= 0.01) {
var trendCol = diff > 0 ? d.green : d.red;
var trendArr = diff > 0 ? "↑" : "↓";
trendHtml = "<span style=\"font-size:9px;color:" + trendCol + ";font-weight:bold;margin-left:4px\">" + trendArr + Math.abs(diff).toFixed(2) + "%</span>";
}
}
var isPinned = tsaPinned.indexOf(s.symbol) >= 0;
var pinBtnHtml = "<button class=\"tsa-pin-btn" + (isPinned ? " pinned" : "") + "\" data-sym=\"" + s.symbol + "\" title=\"" + (isPinned ? "Unpin" : "Pin to top") + "\">📌</button>";
// BB width context — shows whether the signal is in a volatile or quiet period
var bbCtx = calcBBWidth(s.symbol, cachedPriceHistory);
var bbHtml = "";
if (bbCtx && bbCtx.percentile !== null) {
var bbBarW = Math.round(bbCtx.percentile);
var bbColor = bbCtx.percentile <= 15 ? d.yellow :
bbCtx.percentile <= 65 ? d.muted :
bbCtx.percentile <= 85 ? d.green : d.red;
bbHtml = "<div style=\"margin-bottom:6px;border-top:1px solid " + d.divider + ";padding-top:6px\">" +
"<div style=\"display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px\">" +
"<span style=\"color:" + d.muted + "\">Volatility</span>" +
"<span style=\"color:" + bbColor + ";font-weight:bold;" + ms + "\">" + bbCtx.label + " · " + bbBarW + "%</span>" +
"</div>" +
"<div style=\"height:4px;background:" + d.border + ";border-radius:2px\">" +
"<div style=\"height:4px;width:" + bbBarW + "%;background:" + bbColor + ";border-radius:2px\"></div>" +
"</div></div>";
}
html += "<div style=\"margin-bottom:5px\">" +
"<div class=\"tsa-buy-row\" data-symbol=\"" + s.symbol + "\" data-breakdown=\"" + breakdownId + "\" style=\"display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:8px;cursor:pointer;background:" + rowBg + ";border:1px solid " + rowBorder + "\">" +
"<div style=\"display:flex;flex-direction:column;gap:2px\">" +
"<span style=\"font-size:13px;font-weight:bold;color:" + symColor + ";" + ms + "\">" + s.symbol + trendHtml + " " + pinBtnHtml + "</span>" +
"<span style=\"font-size:10px;color:" + d.muted + "\">" + s.reasons.split(" | ").slice(0,2).join(" · ") + invHtml + "</span>" +
"</div><div style=\"display:flex;flex-direction:column;align-items:flex-end;gap:2px\">" +
"<span style=\"font-size:14px;font-weight:bold;color:" + symColor + ";" + ms + "\">" + s.score + "</span>" +
"<span style=\"font-size:9px;color:" + signalColor + ";font-weight:bold\">" + s.signal + "</span>" +
"<span id=\"" + breakdownId + "-caret\" style=\"font-size:9px;color:" + d.muted + "\">▶</span>" +
"</div></div>" +
"<div id=\"" + breakdownId + "\" style=\"display:none;background:" + d.txBg + ";border:1px solid " + d.txBorder + ";border-top:none;border-radius:0 0 8px 8px;padding:10px 12px 8px;margin-top:-4px\">" +
bdHtml +
bbHtml +
buildGapHtml(bd, s.score, s.signal) +
"<div style=\"border-top:1px solid " + d.divider + ";margin-top:4px;padding-top:6px;display:flex;justify-content:space-between;align-items:center\">" +
"<span style=\"font-size:10px;color:" + d.muted + "\">Total</span>" +
"<span style=\"font-size:12px;font-weight:bold;color:" + symColor + ";" + ms + "\">" + s.score + "p · " + s.signal + "</span>" +
"<button class=\"tsa-goto-chart\" data-symbol=\"" + s.symbol + "\" style=\"padding:5px 10px;border-radius:6px;border:1px solid " + d.border + ";background:" + d.bg2 + ";color:" + d.muted + ";font-size:10px;cursor:pointer;font-weight:bold;\">📈 Chart</button>" +
"</div></div>" +
"</div>";
});
html += "</div>";
} else {
html += "<div style=\"padding:10px 14px 6px;background:" + d.bg + "\">" +
"<div style=\"font-size:10px;letter-spacing:0.12em;color:" + d.muted + ";text-transform:uppercase;margin-bottom:8px;font-weight:bold\">Buy signals</div>" +
"<div style=\"color:" + d.muted + ";font-size:11px;padding:8px 0\">No signals right now</div></div>";
}
// WATCH section — owned stocks with CONSIDER score
if (getShowWatch() && watchList.length > 0) {
html += "<div style=\"padding:10px 14px 6px;background:" + d.bg + "\">" +
"<div style=\"font-size:10px;letter-spacing:0.12em;color:" + d.muted + ";text-transform:uppercase;margin-bottom:8px;font-weight:bold\">Watch (" + watchList.length + ")</div>";
watchList.forEach(function(s) {
var watchBreakdownId = "tsa-watch-breakdown-" + s.symbol;
var bd = s.scoreBreakdown || {};
var bdRows = [
{ label: "Drop from weekly peak", pts: bd.drop || 0, max: 60 },
{ label: "Position in range", pts: bd.position || 0, max: 35 },
{ label: "Short-term rise", pts: bd.reversal || 0, max: 40 },
{ label: "MACD", pts: bd.macd || 0, max: 25 },
{ label: "RSI (Torn-calibrated)", pts: bd.rsi || 0, max: 20 },
];
var bdHtml = bdRows.map(function(row) {
var barW = row.max > 0 ? Math.round((row.pts / row.max) * 100) : 0;
var barColor = row.pts >= row.max * 0.75 ? d.green : row.pts >= row.max * 0.4 ? d.yellow : d.muted;
var maxBadge = row.pts === row.max ? " <span style=\"color:" + d.green + ";font-size:9px\">★ MAX</span>" : "";
return "<div style=\"margin-bottom:6px\">" +
"<div style=\"display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px\">" +
"<span style=\"color:" + d.muted + "\">" + row.label + "</span>" +
"<span style=\"color:" + d.text + ";font-weight:bold;" + ms + "\">" + row.pts + " / " + row.max + "p" + maxBadge + "</span>" +
"</div>" +
"<div style=\"height:4px;background:" + d.border + ";border-radius:2px\">" +
"<div style=\"height:4px;width:" + barW + "%;background:" + barColor + ";border-radius:2px\"></div>" +
"</div></div>";
}).join("");
var watchSymColor = s.has_benefit ? d.blue : d.yellow;
var watchRowBg = s.has_benefit ? d.rowBenefit : (isDark2 ? "rgba(255,193,7,0.07)" : "#fffbeb");
var watchRowBorder = s.has_benefit ? d.rowBenefitBorder : (isDark2 ? "rgba(255,193,7,0.25)" : "#fde68a");
// BB width context for Watch stocks
var watchBbCtx = calcBBWidth(s.symbol, cachedPriceHistory);
var watchBbHtml = "";
if (watchBbCtx && watchBbCtx.percentile !== null) {
var watchBbBarW = Math.round(watchBbCtx.percentile);
var watchBbColor = watchBbCtx.percentile <= 15 ? d.yellow :
watchBbCtx.percentile <= 65 ? d.muted :
watchBbCtx.percentile <= 85 ? d.green : d.red;
watchBbHtml = "<div style=\"margin-bottom:6px;border-top:1px solid " + d.divider + ";padding-top:6px\">" +
"<div style=\"display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px\">" +
"<span style=\"color:" + d.muted + "\">Volatility</span>" +
"<span style=\"color:" + watchBbColor + ";font-weight:bold;" + ms + "\">" + watchBbCtx.label + " · " + watchBbBarW + "%</span>" +
"</div>" +
"<div style=\"height:4px;background:" + d.border + ";border-radius:2px\">" +
"<div style=\"height:4px;width:" + watchBbBarW + "%;background:" + watchBbColor + ";border-radius:2px\"></div>" +
"</div></div>";
}
html += "<div style=\"margin-bottom:5px\">" +
"<div class=\"tsa-watch-row\" data-symbol=\"" + s.symbol + "\" data-breakdown=\"" + watchBreakdownId + "\" style=\"display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:8px;cursor:pointer;background:" + watchRowBg + ";border:1px solid " + watchRowBorder + "\">" +
"<div style=\"display:flex;flex-direction:column;gap:2px\">" +
"<span style=\"font-size:13px;font-weight:bold;color:" + watchSymColor + ";" + ms + "\">" + s.symbol + "</span>" +
"<span style=\"font-size:10px;color:" + d.muted + "\">" + s.reasons.split(" | ").slice(0,2).join(" · ") + "</span>" +
"</div><div style=\"display:flex;flex-direction:column;align-items:flex-end;gap:2px\">" +
"<span style=\"font-size:14px;font-weight:bold;color:" + watchSymColor + ";" + ms + "\">" + s.score + "</span>" +
"<span style=\"font-size:9px;color:" + watchSymColor + ";font-weight:bold\">CONSIDER</span>" +
"<span id=\"" + watchBreakdownId + "-caret\" style=\"font-size:9px;color:" + d.muted + "\">▶</span>" +
"</div></div>" +
"<div id=\"" + watchBreakdownId + "\" style=\"display:none;background:" + d.txBg + ";border:1px solid " + d.txBorder + ";border-top:none;border-radius:0 0 8px 8px;padding:10px 12px 8px;margin-top:-4px\">" +
bdHtml +
watchBbHtml +
buildGapHtml(bd, s.score, s.signal) +
"<div style=\"border-top:1px solid " + d.divider + ";margin-top:4px;padding-top:6px;display:flex;justify-content:space-between;align-items:center\">" +
"<span style=\"font-size:10px;color:" + d.muted + "\">Total</span>" +
"<span style=\"font-size:12px;font-weight:bold;color:" + watchSymColor + ";" + ms + "\">" + s.score + "p · CONSIDER</span>" +
"<button class=\"tsa-goto-chart\" data-symbol=\"" + s.symbol + "\" style=\"padding:5px 10px;border-radius:6px;border:1px solid " + d.border + ";background:" + d.bg2 + ";color:" + d.muted + ";font-size:10px;cursor:pointer;font-weight:bold;\">📈 Chart</button>" +
"</div></div>" +
"</div>";
});
html += "</div>";
}
var allOwned = stockResults.filter(function(s) { return s.owned; });
var isBenefitCategory = function(checkSym) {
var o = ownedMap[checkSym];
if (!o) return false;
return o.has_dividend && o.benefit_shares > 0;
};
var isSwingCategory = function(checkSym) {
var o = ownedMap[checkSym];
if (!o) return false;
return o.swing_shares > 0;
};
var swingTrades = allOwned.filter(function(s) { return isSwingCategory(s.symbol); });
swingTrades.sort(function(a, b) { return (b.netProfitPct || -Infinity) - (a.netProfitPct || -Infinity); });
// Browser notifications for PROFIT / STOP LOSS signals on swing trades
(function() {
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
var notified;
try { notified = JSON.parse(localStorage.getItem("tsa_notified_signals") || "{}"); } catch(e) { notified = {}; }
// Remove stale entries for signals that are no longer active
var activeKeys = {};
swingTrades.forEach(function(s) { if (s.sellSignal) activeKeys[s.symbol + "|" + s.sellSignal] = true; });
Object.keys(notified).forEach(function(k) { if (!activeKeys[k]) delete notified[k]; });
// Notify for new signals not yet seen
swingTrades.forEach(function(s) {
if (!s.sellSignal) return;
var key = s.symbol + "|" + s.sellSignal;
if (notified[key]) return;
notified[key] = Date.now();
var pct = (s.netProfitPct !== undefined ? (s.netProfitPct >= 0 ? "+" : "") + s.netProfitPct.toFixed(2) + "%" : "");
new Notification("Torn Stock Signal", {
body: s.symbol + " — " + s.sellSignal + (pct ? " (" + pct + ")" : ""),
icon: "https://www.torn.com/favicon.ico"
});
});
lsSet("tsa_notified_signals", JSON.stringify(notified));
})();
var benefitBlocks = allOwned.filter(function(s) { return isBenefitCategory(s.symbol); });
benefitBlocks.sort(function(a, b) { return (b.dividendProgress || 0) - (a.dividendProgress || 0); });
var renderStockRow = function(s, category) {
var owned = ownedMap[s.symbol];
var isBenefit = category === "benefit";
var displayShares = isBenefit
? ((owned && owned.benefit_shares) || s.shares)
: ((owned && owned.swing_shares) > 0 ? owned.swing_shares : s.shares);
// For mixed stocks in swing section: compute swing-specific avg price
// Transactions are sorted newest-first; swing shares come from newest blocks
var swingNetProfitPct = null;
var swingAvgPrice = null;
if (!isBenefit && owned && owned.benefit_shares > 0 && owned.swing_shares > 0 && s.p_live > 0 && s.transactions && s.transactions.length > 0) {
var swRem = owned.swing_shares;
var swCost = 0, swCount = 0;
s.transactions.forEach(function(t) {
if (swRem <= 0) return;
var take = Math.min(t.shares || 0, swRem);
var price = (t.bought_price && t.bought_price > 0) ? t.bought_price : (s.avg_price || 0);
if (price > 0) { swCost += take * price; swCount += take; }
swRem -= (t.shares || 0);
});
if (swCount > 0) {
swingAvgPrice = swCost / swCount;
swingNetProfitPct = (s.p_live * 0.999 - swingAvgPrice) / swingAvgPrice * 100;
}
}
var displayNetProfitPct = swingNetProfitPct !== null ? swingNetProfitPct : s.netProfitPct;
var displayValue = displayShares > 0 && s.p_live ? "$" + Math.round(displayShares * s.p_live).toLocaleString("en-US") : "";
var sharesStr = displayShares > 0 ? displayShares.toLocaleString("en-US") + " shares" + (displayValue ? " · " + displayValue : "") : "?";
var targetLine = "";
if (!isBenefit && s.avg_price > 0) {
var profitPct = getProfitTarget();
var avgForTarget = swingAvgPrice !== null ? swingAvgPrice : s.avg_price;
var targetPrice = avgForTarget * (1 + profitPct / 100);
var currentPctStr = displayNetProfitPct !== null ? (displayNetProfitPct >= 0 ? "+" : "") + displayNetProfitPct.toFixed(2) + "%" : "";
var currentPctColor = (displayNetProfitPct || 0) >= 0 ? d.green : d.red;
targetLine = "<span style=\"font-size:10px;color:" + d.muted + "\">Avg $" + avgForTarget.toFixed(2) + " → Target <strong style=\"color:" + d.green + "\">$" + targetPrice.toFixed(2) + "</strong> (+" + profitPct.toFixed(1) + "%)" +
(currentPctStr ? " · <strong style=\"color:" + currentPctColor + "\">" + currentPctStr + "</strong>" : "") +
"</span><br>";
}
var pct = displayNetProfitPct !== null
? (displayNetProfitPct >= 0 ? "+" : "") + displayNetProfitPct.toFixed(2) + "%"
: (s.avg_price > 0 && s.p_live > 0
? (((s.p_live * 0.999 - s.avg_price) / s.avg_price * 100) >= 0 ? "+" : "") + ((s.p_live * 0.999 - s.avg_price) / s.avg_price * 100).toFixed(2) + "%"
: "");
var effectiveProfitPct = displayNetProfitPct !== null ? displayNetProfitPct
: (s.avg_price > 0 && s.p_live > 0 ? (s.p_live * 0.999 - s.avg_price) / s.avg_price * 100 : null);
var isProfit = (effectiveProfitPct || 0) >= 0;
var col = isBenefit
? (effectiveProfitPct !== null ? (isProfit ? d.green : d.red) : d.blue)
: (isProfit ? d.green : d.red);
var rowBgStr = isBenefit ? "background:" + d.rowBenefit + ";border:1px solid " + d.rowBenefitBorder
: isProfit ? "background:" + d.rowBuy + ";border:1px solid " + d.rowBuyBorder
: "background:" + d.rowSell + ";border:1px solid " + d.rowSellBorder;
var detailId = "tsa-detail-" + s.symbol + "-" + category;
var txHtml = "";
if (s.transactions && s.transactions.length > 0) {
var txSwingLeft = (!isBenefit && owned && owned.swing_shares > 0) ? owned.swing_shares : Infinity;
s.transactions.forEach(function(t, idx) {
var txDate = t.time_bought ? new Date(t.time_bought * 1000).toLocaleDateString("en-GB", {day:"2-digit",month:"2-digit",year:"2-digit"}) : "?";
var txShares = (t.shares || 0).toLocaleString("en-US");
var txPrice = t.bought_price ? "$" + t.bought_price.toFixed(2) : "?";
var txCurrentVal = t.shares && s.p_live ? t.shares * s.p_live : 0;
var txInvested = t.shares && t.bought_price ? t.shares * t.bought_price : 0;
var txProfit = txCurrentVal - txInvested - txCurrentVal * 0.001;
var txPct = txInvested > 0 ? ((txProfit / txInvested) * 100).toFixed(2) : "?";
var txSign = txProfit >= 0 ? "+" : "";
var txColD = txProfit >= 0 ? d.green : d.red;
// In swing section: transactions beyond swing_shares are benefit blocks
var isBenefitTx = !isBenefit && owned && owned.benefit_shares > 0 && txSwingLeft <= 0;
txSwingLeft -= (t.shares || 0);
var txLabel = isBenefitTx ? "Benefit block" : "Block " + (idx + 1);
var txLabelColor = isBenefitTx ? d.blue : d.muted;
txHtml += "<div " + (!isBenefitTx ? "id=\"tsa-swing-tx-" + s.symbol + "-" + idx + "\" class=\"tsa-swing-tx-row\" data-sym=\"" + s.symbol + "\" data-shares=\"" + (t.shares || 0) + "\" data-label=\"Block " + (idx + 1) + "\" data-state=\"0\" " : "") + "style=\"display:flex;justify-content:space-between;align-items:center;padding:6px 4px;border-bottom:1px solid " + d.divider + ";font-size:11px;" + (!isBenefitTx ? "cursor:pointer;border-radius:4px;" : "") + "\">" +
"<div><span style=\"color:" + txLabelColor + ";" + ms + "\">" + txLabel + "</span><span style=\"color:" + d.muted + ";margin-left:6px\">" + txDate + "</span><br>" +
"<span style=\"color:" + d.text + ";" + ms + "\">" + txShares + " @ " + txPrice + "</span></div>" +
"<div style=\"text-align:right\">" +
"<span style=\"font-weight:bold;color:" + txColD + ";font-size:12px;\">" + txSign + "$" + Math.abs(Math.round(txProfit)).toLocaleString("en-US") + "</span><br>" +
"<span style=\"font-weight:bold;color:" + txColD + ";font-size:11px;\">" + txSign + txPct + "%</span></div></div>";
});
}
var totalInvested = 0, totalCurrentVal = 0;
if (s.transactions && s.transactions.length > 0) {
s.transactions.forEach(function(t) {
var costPrice = (t.bought_price && t.bought_price > 0) ? t.bought_price : (s.avg_price || 0);
if (costPrice > 0) {
totalInvested += (t.shares || 0) * costPrice;
totalCurrentVal += (t.shares || 0) * (s.p_live || 0);
}
});
}
var totalProfit = totalCurrentVal - totalInvested - totalCurrentVal * 0.001;
var totalPct = totalInvested > 0 ? ((totalProfit / totalInvested) * 100).toFixed(2) : null;
var totalCol = (totalPct === null) ? d.muted : (totalProfit >= 0 ? d.green : d.red);
var totalSign = totalProfit >= 0 ? "+" : "";
return "<div style=\"margin-bottom:5px;\">" +
"<div style=\"display:flex;align-items:center;gap:6px;\">" +
"<div style=\"" + rowBgStr + ";flex:1;margin-bottom:0;cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:8px;\" data-detail=\"" + detailId + "\" data-symbol=\"" + s.symbol + "\">" +
"<div style=\"display:flex;flex-direction:column;gap:2px\">" +
"<span style=\"font-size:13px;font-weight:bold;color:" + col + ";" + ms + "\">" + s.symbol + "</span>" +
"<span style=\"font-size:10px;color:" + d.muted + "\">" + sharesStr + "</span><br>" +
targetLine +
"<span style=\"font-size:10px;color:" + d.muted + "\">Score " + s.score + (s.hasDividend ? " · DIV" : "") + (s.reasons ? " · " + s.reasons.split(" | ").slice(0,2).join(" · ") : "") + "</span>" +
"</div><div style=\"display:flex;flex-direction:column;align-items:flex-end;gap:2px\">" +
"<span style=\"font-size:13px;font-weight:bold;color:" + col + ";" + ms + "\">" + pct + "</span>" +
(s.hasDividend ? "<span style=\"font-size:9px;padding:2px 6px;border-radius:10px;font-weight:bold;background:rgba(255,193,7,0.12);color:" + d.yellow + ";border:1px solid rgba(255,193,7,0.3)\">DIV " + s.dividendProgress + "/" + s.dividendFrequency + "d</span>" : "") +
(s.sellSignal ? "<span style=\"font-size:9px;font-weight:bold;color:" + d.red + "\">" + s.sellSignal + "</span>" : "") +
"</div></div>" +
"</div>" +
"<div id=\"" + detailId + "\" style=\"display:none;background:" + d.txBg + ";border:1px solid " + d.txBorder + ";border-radius:0 0 8px 8px;padding:8px 10px;margin-top:-4px;\">" +
// Total P/L summary
"<div style=\"display:flex;justify-content:space-between;align-items:center;background:" + d.bg + ";border-radius:6px;padding:8px 10px;margin-bottom:8px;border:1px solid " + d.border + "\">" +
"<div><div style=\"font-size:9px;color:" + d.muted + ";text-transform:uppercase;letter-spacing:0.08em;margin-bottom:2px\">Total P/L</div>" +
"<div style=\"font-size:15px;font-weight:bold;color:" + totalCol + "\">" + (totalPct !== null ? totalSign + "$" + Math.abs(Math.round(totalProfit)).toLocaleString("en-US") : "N/A") + "</div></div>" +
"<div style=\"text-align:right\"><div style=\"font-size:9px;color:" + d.muted + ";text-transform:uppercase;letter-spacing:0.08em;margin-bottom:2px\">Return</div>" +
"<div style=\"font-size:15px;font-weight:bold;color:" + totalCol + "\">" + (totalPct !== null ? totalSign + totalPct + "%" : "N/A") + "</div></div>" +
"<button class=\"tsa-goto-chart\" data-symbol=\"" + s.symbol + "\" style=\"padding:6px 10px;border-radius:6px;border:1px solid " + d.border + ";background:" + d.bg2 + ";color:" + d.muted + ";font-size:10px;cursor:pointer;font-weight:bold;\">📈 Chart</button>" +
"</div>" +
(function() {
// Entry slippage: compare QT buy intent price vs actual avg_price
try {
var intentRaw = localStorage.getItem("qt_intent_" + s.symbol);
if (intentRaw && s.avg_price > 0) {
var intent = JSON.parse(intentRaw);
var ageSecs = Math.floor(Date.now() / 1000) - (intent.ts || 0);
if (ageSecs < 86400 && intent.price > 0) {
var slipPct = (s.avg_price - intent.price) / intent.price * 100;
if (Math.abs(slipPct) >= 0.5) {
var slipColor = slipPct > 0 ? d.red : d.green;
var slipSign = slipPct > 0 ? "+" : "";
return "<div style=\"display:flex;align-items:center;gap:6px;padding:5px 8px;margin-bottom:6px;border-radius:6px;background:" + (slipPct > 0 ? "rgba(255,76,106,0.08)" : "rgba(76,255,145,0.08)") + ";border:1px solid " + (slipPct > 0 ? "rgba(255,76,106,0.25)" : "rgba(76,255,145,0.25)") + "\">" +
"<span style=\"font-size:11px\">" + (slipPct > 0 ? "⚠" : "✓") + "</span>" +
"<span style=\"font-size:10px;color:" + d.muted + "\">Entry slippage: intended <strong style=\"color:" + d.text + "\">$" + intent.price.toFixed(2) + "</strong> · paid <strong style=\"color:" + slipColor + "\">$" + s.avg_price.toFixed(2) + "</strong> <strong style=\"color:" + slipColor + "\">(" + slipSign + slipPct.toFixed(1) + "%)</strong></span>" +
"</div>";
}
}
}
} catch(e) {}
return "";
})() +
"<div style=\"font-size:10px;color:" + d.muted + ";margin-bottom:4px;font-weight:bold;text-transform:uppercase;letter-spacing:0.08em;" + ms + "\">Transactions</div>" +
"<div style=\"font-size:11px;color:" + d.muted + ";margin-bottom:6px;" + ms + "\">Avg: <strong style=\"color:" + d.text + "\">" + (s.avg_price > 0 ? "$" + s.avg_price.toFixed(2) : "N/A") + "</strong> · Live: <strong style=\"color:" + d.text + "\">$" + s.p_live.toFixed(2) + "</strong>" + (s.hoursHeld ? " · <span style=\"color:" + d.muted + "\">held " + s.hoursHeld + "h</span>" : "") + "</div>" +
txHtml + "</div></div>";
};
if (allOwned.length > 0) {
html += "<hr style=\"border:none;border-top:1px solid " + d.divider + ";margin:6px 14px\">";
if (swingTrades.length > 0) {
var swingCollapsed = lsGet("tsa_swing_collapsed", "false") === "true";
html += "<div style=\"padding:10px 14px 6px;background:" + d.bg + "\">" +
"<div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;cursor:pointer;\" id=\"tsa-swing-header\">" +
"<span style=\"font-size:10px;letter-spacing:0.12em;color:" + d.muted + ";text-transform:uppercase;font-weight:bold;" + ms + "\">Swing trades (" + swingTrades.length + ")</span>" +
"<span style=\"font-size:14px;color:" + d.muted + ";\">" + (swingCollapsed ? "►" : "▼") + "</span></div>" +
"<div id=\"tsa-swing-body\" style=\"display:" + (swingCollapsed ? "none" : "block") + "\">";
swingTrades.forEach(function(s) { html += renderStockRow(s, "swing"); });
html += "</div></div>";
}
if (benefitBlocks.length > 0) {
if (swingTrades.length > 0) html += "<hr style=\"border:none;border-top:1px solid " + d.divider + ";margin:6px 14px\">";
var benefitCollapsed = lsGet("tsa_benefit_collapsed", "false") === "true";
html += "<div style=\"padding:10px 14px 6px;background:" + d.bg + "\">" +
"<div style=\"display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;cursor:pointer;\" id=\"tsa-benefit-header\">" +
"<span style=\"font-size:10px;letter-spacing:0.12em;color:" + d.muted + ";text-transform:uppercase;font-weight:bold;" + ms + "\">Benefit blocks (" + benefitBlocks.length + ")</span>" +
"<span style=\"font-size:14px;color:" + d.muted + ";\">" + (benefitCollapsed ? "►" : "▼") + "</span></div>" +
"<div id=\"tsa-benefit-body\" style=\"display:" + (benefitCollapsed ? "none" : "block") + "\">";
benefitBlocks.forEach(function(s) { html += renderStockRow(s, "benefit"); });
html += "</div></div>";
}
}
var autoRefreshMins = getAutoRefreshInterval();
var autoRefreshLabel = autoRefreshMins > 0 ? " · Auto " + autoRefreshMins + "m" : "";
var histSyms = Object.keys(cachedPriceHistory);
var maxHistHours = 0;
histSyms.forEach(function(sym) {
var entries = cachedPriceHistory[sym];
if (!entries || entries.length < 2) return;
var hrs = Math.round((entries[entries.length - 1].ts - entries[0].ts) / 3600000);
if (hrs > maxHistHours) maxHistHours = hrs;
});
var histLabel = histSyms.length > 0 ? " · " + histSyms.length + " stocks · " + maxHistHours + "h hist" : "";
html += "<div style=\"padding:10px 14px;display:flex;justify-content:space-between;align-items:center;border-top:1px solid " + d.divider + ";background:" + d.bg + "\">" +
"<span style=\"font-size:10px;color:" + d.muted + "\">Updated: " + new Date().toLocaleTimeString("en-GB") + autoRefreshLabel + "<span id='tsa-countdown'></span></span>" +
"<span style=\"font-size:10px;color:" + d.muted + "\">Storage: " + getTsaStorageSize() + histLabel + "</span>" +
"</div>" +
"<button id='tsa-scroll-top' title='Scroll to top'>↑</button>";
scheduleAutoRefresh();
content.innerHTML = html;
// Buy signal chart click handler
function drawChart(sym, chartId, stockData) {
var chartDiv = document.getElementById(chartId);
if (!chartDiv) return;
var canvas = document.getElementById("canvas-" + sym);
if (!canvas) return;
var r = stockData ? stockData.find(function(x){ return x.stock === sym; }) : null;
if (!r) { chartDiv.innerHTML += "<div style='font-size:9px;color:#888;text-align:center'>No data</div>"; return; }
// Chronological order of spread prices
var pts = [
{l:"1W", p: parseFloat((r.interval && r.interval.w1 && r.interval.w1.price)) || 0},
{l:"4D", p: parseFloat((r.interval && r.interval.d4 && r.interval.d4.price)) || 0},
{l:"2D", p: parseFloat((r.interval && r.interval.d2 && r.interval.d2.price)) || 0},
{l:"1D", p: parseFloat((r.interval && r.interval.d1 && r.interval.d1.price)) || 0},
{l:"12H", p: parseFloat((r.interval && r.interval.h12 && r.interval.h12.price)) || 0},
{l:"8H", p: parseFloat((r.interval && r.interval.h8 && r.interval.h8.price)) || 0},
{l:"4H", p: parseFloat((r.interval && r.interval.h4 && r.interval.h4.price)) || 0},
{l:"2H", p: parseFloat((r.interval && r.interval.h2 && r.interval.h2.price)) || 0},
{l:"1H", p: parseFloat((r.interval && r.interval.h1 && r.interval.h1.price)) || 0},
{l:"Now", p: parseFloat(r.price) || 0}
].filter(function(pt){ return pt.p > 0; });
if (pts.length < 2) return;
var isDarkC = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
var lineColor = isDarkC ? "#4cff91" : "#1a8a45";
var gridColor = isDarkC ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.05)";
var textColor = isDarkC ? "#7a7a9a" : "#888";
var w = canvas.offsetWidth || 280;
var h = 80;
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext("2d");
if (!ctx) return;
var prices = pts.map(function(pt){ return pt.p; });
var minP = Math.min.apply(null, prices);
var maxP = Math.max.apply(null, prices);
var range = maxP - minP || 1;
var pad = 8;
ctx.clearRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
[0.25, 0.5, 0.75].forEach(function(f) {
var y = pad + (1-f) * (h - pad*2);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
});
// Price line
ctx.strokeStyle = lineColor;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.beginPath();
pts.forEach(function(pt, i) {
var x = pad + (i / (pts.length-1)) * (w - pad*2);
var y = pad + (1 - (pt.p - minP) / range) * (h - pad*2);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
// Fill under line
ctx.fillStyle = isDarkC ? "rgba(76,255,145,0.06)" : "rgba(26,138,69,0.06)";
ctx.lineTo(pad + (pts.length-1)/(pts.length-1) * (w-pad*2), h-pad);
ctx.lineTo(pad, h-pad);
ctx.closePath();
ctx.fill();
// Last point dot
var lastX = w - pad;
var lastY = pad + (1 - (pts[pts.length-1].p - minP) / range) * (h - pad*2);
ctx.beginPath();
ctx.arc(lastX, lastY, 3, 0, Math.PI*2);
ctx.fillStyle = lineColor;
ctx.fill();
// Min/Max labels
ctx.fillStyle = textColor;
ctx.font = "9px Arial";
ctx.textAlign = "right";
ctx.fillText("$" + maxP.toFixed(2), w-2, pad+8);
ctx.fillText("$" + minP.toFixed(2), w-2, h-pad+1);
}
// Helper: set stock in Quick Trade dropdown
function qtSetStock(sym) {
var hidden = document.getElementById("qt-stock");
var search = document.getElementById("qt-stock-search");
if (hidden) hidden.value = sym;
if (search) search.value = sym;
lsSet("qt_last_stock", sym);
qtDrawChart(sym);
qtUpdateExec();
}
document.querySelectorAll(".tsa-watch-row").forEach(function(row) {
row.addEventListener("click", function() {
var sym = row.dataset.symbol;
qtSetStock(sym);
var bdId = row.dataset.breakdown;
if (bdId) {
var panel = document.getElementById(bdId);
if (panel) {
var open = panel.style.display !== "none";
panel.style.display = open ? "none" : "block";
var caret = document.getElementById(bdId + "-caret");
if (caret) caret.textContent = open ? "▶" : "▼";
}
}
});
});
document.querySelectorAll(".tsa-buy-row").forEach(function(row) {
row.addEventListener("click", function() {
var sym = row.dataset.symbol;
qtSetStock(sym);
// Toggle score breakdown panel
var bdId = row.dataset.breakdown;
if (bdId) {
var panel = document.getElementById(bdId);
if (panel) {
var open = panel.style.display !== "none";
panel.style.display = open ? "none" : "block";
var caret = document.getElementById(bdId + "-caret");
if (caret) caret.textContent = open ? "▶" : "▼";
}
}
});
});
// Pin button — stop propagation so row click doesn't also fire
document.querySelectorAll(".tsa-pin-btn").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.stopPropagation();
var sym = btn.dataset.sym;
var idx = tsaPinned.indexOf(sym);
if (idx >= 0) { tsaPinned.splice(idx, 1); } else { tsaPinned.push(sym); }
lsSet("tsa_pinned", JSON.stringify(tsaPinned));
loadData();
});
});
// Scroll-to-top button visibility
var overlayEl = document.getElementById("tsa-overlay");
var scrollTopBtn = document.getElementById("tsa-scroll-top");
if (scrollTopBtn) {
scrollTopBtn.style.display = "flex";
scrollTopBtn.style.opacity = overlayEl && overlayEl.scrollTop > 80 ? "1" : "0";
scrollTopBtn.style.transition = "opacity 0.2s";
scrollTopBtn.addEventListener("click", function() {
if (overlayEl) overlayEl.scrollTo({ top: 0, behavior: "smooth" });
});
if (overlayEl) {
overlayEl.addEventListener("scroll", function() {
if (!scrollTopBtn) return;
scrollTopBtn.style.opacity = overlayEl.scrollTop > 80 ? "1" : "0";
}, { passive: true });
}
}
// Chart button in detail panel
document.querySelectorAll(".tsa-goto-chart").forEach(function(btn) {
btn.addEventListener("click", function(e) {
e.stopPropagation();
var sym = btn.dataset.symbol;
if (!isDesktop) {
var overlay = document.getElementById("tsa-overlay");
if (overlay) overlay.style.display = "none";
}
var allImgs = document.querySelectorAll("img[src*='/logos/']");
allImgs.forEach(function(img) {
if (img.src.toLowerCase().indexOf("/" + sym.toLowerCase() + ".svg") >= 0) {
var el = img;
for (var i = 0; i < 8; i++) {
el = el.parentElement;
if (!el) break;
if (el.className && el.className.indexOf("stock___") >= 0) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
break;
}
}
}
});
});
});
document.querySelectorAll("[data-detail]").forEach(function(row) {
row.addEventListener("click", function() {
if (row.dataset.symbol) qtSetStock(row.dataset.symbol);
var panel = document.getElementById(row.dataset.detail);
if (!panel) return;
var opening = panel.style.display === "none";
panel.style.display = opening ? "block" : "none";
if (opening) {
var overlay = document.getElementById("tsa-overlay");
if (overlay) {
var targetTop = row.offsetTop - 50; // 50px offset for sticky header
overlay.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
}
}
});
});
var realizedRow = document.getElementById("tsa-realized-row");
if (realizedRow) realizedRow.addEventListener("click", function() {
lsSet("tsa_realized_expanded", String(lsGet("tsa_realized_expanded", "false") !== "true"));
loadData();
});
var swingHeader = document.getElementById("tsa-swing-header");
if (swingHeader) swingHeader.addEventListener("click", function() {
lsSet("tsa_swing_collapsed", String(lsGet("tsa_swing_collapsed", "false") !== "true"));
loadData();
});
content.querySelectorAll(".tsa-swing-tx-row").forEach(function(row) {
row.addEventListener("click", function(e) {
e.stopPropagation();
var sym = row.dataset.sym;
var shares = parseInt(row.dataset.shares, 10);
var label = row.dataset.label;
var state = row.dataset.state || "0";
var isDarkSell = document.getElementById("tsa-overlay").classList.contains("tsa-dark");
var sellColor = isDarkSell ? "#ff4c6a" : "#cc2222";
var sellBg = isDarkSell ? "rgba(255,76,106,0.1)" : "rgba(204,34,34,0.08)";
if (state === "0") {
row.dataset.state = "1";
row.dataset.origHtml = row.innerHTML;
row.style.background = sellBg;
row.style.borderLeft = "2px solid " + sellColor;
row.style.paddingLeft = "8px";
row.innerHTML =
"<div style=\"display:flex;flex-direction:column;gap:2px\">" +
"<span style=\"font-size:10px;font-weight:700;color:" + sellColor + ";font-family:JetBrains Mono,monospace\">▲ Tap again to confirm</span>" +
"<span style=\"font-size:9px;color:" + sellColor + ";font-family:JetBrains Mono,monospace\">SELL " + shares.toLocaleString("en-US") + " " + sym + " · " + label + "</span>" +
"</div>";
setTimeout(function() {
if (row.dataset.state === "1") {
row.dataset.state = "0";
row.style.background = "";
row.style.borderLeft = "";
row.style.paddingLeft = "";
row.innerHTML = row.dataset.origHtml || "";
}
}, 4000);
} else {
row.dataset.state = "0";
var parent = row.parentNode;
if (parent) parent.removeChild(row);
qtBuildMaps();
qtPostTrade(sym, shares, "sellShares", "Sold " + shares.toLocaleString("en-US") + " " + sym + " (" + label + ")");
}
});
});
var benefitHeader = document.getElementById("tsa-benefit-header");
if (benefitHeader) benefitHeader.addEventListener("click", function() {
lsSet("tsa_benefit_collapsed", String(lsGet("tsa_benefit_collapsed", "false") !== "true"));
loadData();
});
}).catch(function(e) {
content.innerHTML = "<div class=\"tsa-error\">Error: " + escHtml(e.message) + "</div>" +
"<div class=\"tsa-footer\"><span></span><button class=\"tsa-refresh\" id=\"tsa-refresh-btn\">Retry</button></div>";
var retryBtn = document.getElementById("tsa-refresh-btn");
if (retryBtn) retryBtn.addEventListener("click", loadData);
});
}
// ── Signal history: logs buy signals with timestamp to localStorage ──
var SIGNAL_HISTORY_KEY = "tsa_signal_history";
var SIGNAL_HISTORY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
function loadSignalHistory() {
try { return JSON.parse(localStorage.getItem(SIGNAL_HISTORY_KEY)) || []; } catch(e) { return []; }
}
function saveSignalHistory(history) {
try { localStorage.setItem(SIGNAL_HISTORY_KEY, JSON.stringify(history)); } catch(e) {}
}
function recordSignals(stockResults) {
var now = Date.now();
var nowMin = Math.round(now / 60000) * 60000;
var cutoff = now - SIGNAL_HISTORY_MAX_AGE;
var history = loadSignalHistory().filter(function(e) { return e.ts >= cutoff; });
// Build set of sym+minute combos already logged
var existing = {};
history.forEach(function(e) {
existing[e.sym + "_" + Math.round(e.ts / 60000)] = true;
});
stockResults.forEach(function(s) {
if (s.signal === "STRONG BUY" || s.signal === "BUY") {
var key = s.symbol + "_" + Math.round(nowMin / 60000);
if (!existing[key]) {
history.push({ ts: nowMin, sym: s.symbol, signal: s.signal, score: s.score, price: s.p_live });
existing[key] = true;
}
}
});
saveSignalHistory(history);
}
// ── Price + investors history storage ──
var HISTORY_KEY = "tsa_price_history";
var INVESTOR_HISTORY_KEY = "tsa_investor_history";
var HISTORY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // default 30 days
function getHistoryMaxAge() {
var days = parseInt(lsGet("tsa_history_days", "30"), 10);
if (isNaN(days) || days < 1) days = 1;
if (days > 30) days = 30;
return days * 24 * 60 * 60 * 1000;
}
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || {}; } catch(e) { return {}; }
}
function saveHistory(history) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
} catch(e) {
// Quota exceeded — try pruning to progressively shorter windows before giving up
var fallbackDays = [3, 1];
var saved = false;
for (var fi = 0; fi < fallbackDays.length; fi++) {
var cutoff = Date.now() - fallbackDays[fi] * 24 * 60 * 60 * 1000;
var reduced = {};
Object.keys(history).forEach(function(sym) {
var filtered = history[sym].filter(function(p) { return p.ts >= cutoff; });
if (filtered.length > 0) reduced[sym] = filtered;
});
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(reduced));
saved = true;
break;
} catch(e2) {}
}
if (!saved && !saveHistory._warned) {
saveHistory._warned = true;
showToast("Storage full — price history could not be saved", "warn");
}
}
}
function loadInvestorHistory() {
try { return JSON.parse(localStorage.getItem(INVESTOR_HISTORY_KEY)) || {}; } catch(e) { return {}; }
}
function saveInvestorHistory(history) {
try { localStorage.setItem(INVESTOR_HISTORY_KEY, JSON.stringify(history)); } catch(e) {}
}
function pruneInvestorHistory(history) {
var cutoff = Date.now() - getHistoryMaxAge();
Object.keys(history).forEach(function(sym) {
history[sym] = history[sym].filter(function(p) { return p.ts >= cutoff; });
if (history[sym].length === 0) delete history[sym];
});
return history;
}
// Returns investor delta vs ~24h ago for a given symbol, or null if no data
// Pass pre-loaded history to avoid repeated localStorage reads
function getInvestorDelta(sym, cachedHistory) {
var history = cachedHistory || loadInvestorHistory();
var entries = history[sym.toUpperCase()];
if (!entries || entries.length < 2) return null;
var now = Date.now();
var target = now - 24 * 3600 * 1000;
var closest = null, closestDiff = Infinity;
entries.forEach(function(e) {
var diff = Math.abs(e.ts - target);
if (diff < closestDiff) { closestDiff = diff; closest = e; }
});
if (!closest) return null;
if (closest.ts > now - 30 * 60 * 1000) return null;
var latest = entries[entries.length - 1];
return latest.investors - closest.investors;
}
function pruneHistory(history) {
var cutoff = Date.now() - getHistoryMaxAge();
Object.keys(history).forEach(function(sym) {
history[sym] = history[sym].filter(function(p) { return p.ts >= cutoff; });
if (history[sym].length === 0) delete history[sym];
});
return history;
}
function recordPrices(raw) {
var history = pruneHistory(loadHistory());
var now = Date.now();
var intervalMs = { "w1":7*24*3600*1000,"d7":7*24*3600*1000,"d6":6*24*3600*1000,"d5":5*24*3600*1000,"d4":4*24*3600*1000,"d3":3*24*3600*1000,"d2":2*24*3600*1000,"d1":24*3600*1000,"h20":20*3600*1000,"h16":16*3600*1000,"h12":12*3600*1000,"h10":10*3600*1000,"h8":8*3600*1000,"h6":6*3600*1000,"h5":5*3600*1000,"h4":4*3600*1000,"h3":3*3600*1000,"h2":2*3600*1000,"h1":3600*1000,"m30":30*60*1000 };
var INTERVALS = Object.keys(intervalMs);
raw.forEach(function(r) {
var sym = r.stock;
if (!sym) return;
if (!history[sym]) history[sym] = [];
// Build a map of existing ts values for fast dedup
var existingTs = {};
history[sym].forEach(function(p) { existingTs[p.ts] = true; });
// Store all 20 intervals — skip if this exact ts already exists
INTERVALS.forEach(function(key) {
var p = parseFloat((r.interval && r.interval[key] && r.interval[key].price)) || 0;
if (p <= 0) return;
var ts = now - (intervalMs[key] || 0);
// Round to nearest minute to allow dedup across sessions
ts = Math.round(ts / 60000) * 60000;
if (!existingTs[ts]) {
history[sym].push({ ts: ts, price: p });
existingTs[ts] = true;
}
});
// Store live price — always overwrite if same-minute ts exists
var p_live = parseFloat(r.price) || 0;
if (p_live > 0) {
var liveTs = Math.round(now / 60000) * 60000;
// Remove old entry for this ts if exists, then add fresh
history[sym] = history[sym].filter(function(p) { return p.ts !== liveTs; });
history[sym].push({ ts: liveTs, price: p_live });
}
// Sort by time
history[sym].sort(function(a, b) { return a.ts - b.ts; });
});
saveHistory(history);
// Record investor counts
var invHistory = pruneInvestorHistory(loadInvestorHistory());
var nowMin = Math.round(now / 60000) * 60000;
raw.forEach(function(r) {
var sym = r.stock;
if (!sym || !r.investors) return;
var investors = parseInt(r.investors, 10) || 0;
if (investors <= 0) return;
if (!invHistory[sym]) invHistory[sym] = [];
// Dedup by minute
var existingTs = {};
invHistory[sym].forEach(function(e) { existingTs[e.ts] = true; });
if (!existingTs[nowMin]) {
invHistory[sym].push({ ts: nowMin, investors: investors });
invHistory[sym].sort(function(a, b) { return a.ts - b.ts; });
}
});
saveInvestorHistory(invHistory);
// Return history so caller can reuse without a second localStorage parse
return history;
}
// Track recommendation tap state per symbol
var qtRecTapState = {};
function updateQtRecommendation(sym) {
var recDiv = document.getElementById("qt-rec");
if (!recDiv) {
// Bar not ready yet — retry after delay
setTimeout(function() { updateQtRecommendation(sym); }, 500);
return;
}
if (!lastBestRec) { recDiv.style.display = "none"; return; }
var rec = lastBestRec;
var cash = typeof qtGetMoneyFast === "function" ? qtGetMoneyFast() : 0;
var canAfford = cash >= rec.cost;
var color = canAfford ? "#4cff91" : "#ff4c6a";
var border = canAfford ? "rgba(76,255,145,0.3)" : "rgba(255,76,106,0.3)";
var bg = canAfford ? "rgba(76,255,145,0.07)" : "rgba(255,76,106,0.06)";
var recSym = rec.sym;
var recTier = "T" + rec.tierInfo.nextIncrement;
var recShares = rec.tierInfo.sharesNeeded;
var recCost = rec.cost;
var tapState = qtRecTapState[recSym + recTier] || 0;
var label = tapState === 0
? "💡 " + recSym + " " + recTier + " · " + recShares.toLocaleString("en-US") + " shares · " + fmRoi(recCost)
: "▲ Buy " + recShares.toLocaleString("en-US") + " " + recSym + " — tap to confirm";
recDiv.style.display = "block";
recDiv.innerHTML = "<button id='qt-rec-btn' style='width:100%;padding:6px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg + ";color:" + color + ";font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;cursor:pointer;text-align:left;'>" + label + "</button>";
document.getElementById("qt-rec-btn").addEventListener("click", function() {
var state = qtRecTapState[recSym + recTier] || 0;
if (state === 0) {
// Press 1: Scroll to stock
qtBuildMaps();
var allImgs = document.querySelectorAll("img[src*='/logos/']");
allImgs.forEach(function(img) {
if (img.src.toLowerCase().indexOf("/" + recSym.toLowerCase() + ".svg") >= 0) {
var el = img;
for (var i = 0; i < 8; i++) {
el = el.parentElement;
if (!el) break;
if (el.className && el.className.indexOf("stock___") >= 0) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
break;
}
}
}
});
qtRecTapState[recSym + recTier] = 1;
updateQtRecommendation(null);
} else {
// Press 2: Buy
qtBuildMaps();
qtPostTrade(recSym, recShares, "buyShares", "Bought " + recShares.toLocaleString("en-US") + " " + recSym + " (" + recTier + ")");
qtRecTapState[recSym + recTier] = 0;
updateQtRecommendation(null);
}
});
}
function getRoiRecommendation(sym) {
if (!lastOwnedMap || !lastRaw) return null;
var tierInfo = calcNextTier(sym, lastOwnedMap, lastRaw);
if (!tierInfo || tierInfo.sharesNeeded <= 0) return null;
// Check capital
var liveEntry = lastRaw.find(function(x) { return x.stock === sym.toUpperCase(); });
if (!liveEntry) return null;
// Estimate available capital from cash (rough — exact is in ROI planner)
return {
sym: sym,
sharesNeeded: tierInfo.sharesNeeded,
cost: tierInfo.cost,
tier: "T" + tierInfo.nextIncrement,
livePrice: tierInfo.livePrice
};
}
var QT_DEFAULT_AMOUNTS = [1000000, 3000000, 5000000, 10000000, 25000000, 50000000, 100000000];
var qtAmounts = (function() {
try {
var stored = localStorage.getItem("qt_amounts");
var parsed = stored ? JSON.parse(stored) : null;
return (Array.isArray(parsed) && parsed.length > 0) ? parsed : QT_DEFAULT_AMOUNTS.slice();
} catch(e) { return QT_DEFAULT_AMOUNTS.slice(); }
})();
var qtMode = "buy";
var qtSelAmt = null;
var qtEditMode = false;
var QT_STOCKS = ["ASS","BAG","CBD","CNC","ELT","EVL","EWM","FHG","GRN","HRG",
"IIL","IOU","IST","LAG","LOS","LSC","MCS","MSG","MUN","PRN",
"PTS","SYM","SYS","TCC","TCI","TCM","TCP","TCT","TGP","THS",
"TMI","TSB","WLT","WSU","YAZ"];
// ── TheALFA's exact variables (from his insert()) ──
var qt_stocks = {}, qt_stockId = {}, qt_stockRows = {}, qt_localShareCache = {};
// ── TheALFA's exact insert() DOM parsing ──
function qtBuildMaps() {
$("ul[class^='stock_']").each(function() {
var sym = $("img", $(this)).attr("src").split("logos/")[1].split(".svg")[0];
qt_stockId[sym] = $(this).attr("id");
qt_stocks[sym] = $("div[class^='price_']", $(this));
qt_stockRows[sym] = $(this);
});
}
// ── TheALFA's exact functions ──
function qtGetRFC() { var c = document.cookie.match(/rfc_v=([^;]+)/); return c ? c[1] : ""; }
function qtParseTornNumber(val) {
if (typeof val !== "string") return 0;
val = val.trim().toLowerCase();
if (!val) return 0;
var n;
if (val.endsWith("k")) { n = parseFloat(val.replace("k", "")); return isNaN(n) ? 0 : n * 1000; }
if (val.endsWith("m")) { n = parseFloat(val.replace("m", "")); return isNaN(n) ? 0 : n * 1000000; }
if (val.endsWith("b")) { n = parseFloat(val.replace("b", "")); return isNaN(n) ? 0 : n * 1000000000; }
n = parseFloat(val.replace(/,/g, ""));
return isNaN(n) ? 0 : n;
}
function qtGetPrice(id) {
if (!qt_stocks[id]) return 0;
return parseFloat($(qt_stocks[id]).text().replace(/,/g, "")) || 0;
}
function qtGetOwnedShares(id) {
if (qt_localShareCache[id] !== undefined) return qt_localShareCache[id];
var row = qt_stockRows[id];
if (!row) return 0;
var mobileEl = row.find("p[class^='count']");
if (mobileEl.length > 0) return parseFloat(mobileEl.text().replace(/,/g, "")) || 0;
var cols = row.children("div");
if (cols.length >= 5) return parseFloat($(cols[4]).text().replace(/,/g, "")) || 0;
return 0;
}
function qtUpdateLocalCache(sym, amt) {
var current = qtGetOwnedShares(sym);
qt_localShareCache[sym] = Math.max(0, current + amt);
}
function qtGetMoneyFast() {
var dataMoney = $("#user-money").attr("data-money");
if (dataMoney) return parseFloat(dataMoney);
var textMoney = $("#user-money").text();
return textMoney ? qtParseTornNumber(textMoney) : 0;
}
// Exact copy of TheALFA's getBenefitTier()
function qtGetBenefitTier(sym, shares) {
var data = BENEFIT_REQ[sym];
if (!data) return { tier: 0, next: 0 };
// TheALFA uses STOCK_DATA which has type "P" for passive stocks
// Our BENEFIT_REQ only stores the base number, so we check our own map
if (PASSIVE_STOCKS.indexOf(sym) >= 0) {
return (shares >= data) ? { tier: 1, next: data } : { tier: 0, next: data };
}
var multiplier = 1;
while (shares >= data * (multiplier * 2)) { multiplier *= 2; }
return (shares < data) ? { tier: 0, next: data } : { tier: multiplier, next: data * multiplier * 2 };
}
// ── TheALFA's exact postTrade() ──
function qtPostTrade(symb, amt, step, msg) {
// Record buy intent so we can detect entry slippage after the next data load
if (step === "buyShares") {
var intentPrice = qtGetPrice(symb);
if (intentPrice > 0) {
lsSet("qt_intent_" + symb, JSON.stringify({ price: intentPrice, ts: Math.floor(Date.now() / 1000) }));
}
}
var execBtn = document.getElementById("qt-exec");
if (execBtn) { execBtn.disabled = true; execBtn.textContent = "Processing..."; }
$.post("https://www.torn.com/page.php?sid=StockMarket&step=" + step + "&rfcv=" + qtGetRFC(),
{ stockId: qt_stockId[symb], amount: amt })
.done(function(r) {
try {
if (typeof r === "string") r = JSON.parse(r);
if (r.success) {
if (execBtn) { execBtn.textContent = "✓ " + msg; setTimeout(function(){ qtUpdateExec(); }, 3000); }
qtUpdateLocalCache(symb, step === "buyShares" ? amt : -amt);
showToast(msg, "success");
} else {
showToast(r.text || "Trade failed", "error");
if (execBtn) { execBtn.disabled = false; qtUpdateExec(); }
}
} catch(e) {
if (execBtn) { execBtn.textContent = "✓ " + msg; setTimeout(function(){ qtUpdateExec(); }, 3000); }
}
})
.fail(function() {
showToast("Request failed", "error");
if (execBtn) { execBtn.disabled = false; qtUpdateExec(); }
});
}
// ── TheALFA's exact vault() ──
function qtVault(symb) {
var money = qtGetMoneyFast();
if (money === 0) { showToast("No money found", "warn"); return; }
var price = qtGetPrice(symb);
var amt = Math.floor(money / price);
if (amt <= 0) { showToast("Amount too small", "warn"); return; }
qtPostTrade(symb, amt, "buyShares", "Vaulted $" + (amt*price).toLocaleString() + " (" + amt + " shares)");
}
// ── TheALFA's exact withdraw() ──
// ── Shared benefit lock check — used by all three sell paths ──
// Returns max shares that can be safely sold (Infinity = no restriction).
// Blocks by returning 0 when all shares are locked or count is unverifiable.
function qtBenefitLockMax(symb) {
var currentOwned = qtGetOwnedShares(symb);
var oe = lastOwnedMap ? lastOwnedMap[symb.toUpperCase()] : null;
if (oe && oe.benefit_shares > 0) {
// Precise path: live count minus static benefit_shares (benefit_shares never
// decreases from selling, only from losing a tier which requires buying first)
return Math.max(0, currentOwned - oe.benefit_shares);
}
if (BENEFIT_REQ[symb]) {
// Fallback: tier-based check (TSA panel not yet loaded)
if (currentOwned === 0) return -1; // sentinel: cannot verify → block
var curTier = qtGetBenefitTier(symb, currentOwned);
if (curTier.tier === 0) return Infinity; // no benefit tier → no restriction
// Max sellable = current owned minus shares needed to stay at current tier
var keepShares = PASSIVE_STOCKS.indexOf(symb) >= 0
? BENEFIT_REQ[symb]
: BENEFIT_REQ[symb] * (2 * curTier.tier - 1);
return Math.max(0, currentOwned - keepShares);
}
return Infinity; // not a benefit stock
}
function qtWithdraw(symb, val) {
var price = qtGetPrice(symb);
if (price <= 0) { showToast("Could not read price for " + symb, "error"); return; }
var shares = Math.ceil((val / 0.999) / price);
if ($("#qt-lock-benefit").is(":checked")) {
var maxSell = qtBenefitLockMax(symb);
if (maxSell === -1) { showToast("Benefit Lock: Cannot verify share count — blocked for safety", "warn"); return; }
if (maxSell === 0) { showToast("Benefit Lock: All shares are benefit block shares — cannot sell", "warn"); return; }
if (maxSell !== Infinity && shares > maxSell) {
shares = maxSell;
showToast("Benefit Lock: Capped to " + maxSell.toLocaleString() + " swing shares", "warn");
}
}
qtPostTrade(symb, shares, "sellShares", "Withdrawn approx $" + val.toLocaleString());
}
// ── TheALFA's exact withdrawAll() ──
function qtWithdrawAll(symb) {
var owned = qtGetOwnedShares(symb);
if (owned <= 0) { showToast("You have no shares of " + symb, "warn"); return; }
var sellAmt = owned;
if ($("#qt-lock-benefit").is(":checked")) {
var maxSell = qtBenefitLockMax(symb);
if (maxSell === -1) { showToast("Benefit Lock: Cannot verify share count — blocked for safety", "warn"); return; }
if (maxSell === 0) { showToast("Benefit Lock: All shares are benefit block shares — cannot sell", "warn"); return; }
if (maxSell !== Infinity) sellAmt = Math.min(sellAmt, maxSell);
}
qtPostTrade(symb, sellAmt, "sellShares", "Sold all " + sellAmt.toLocaleString() + " shares");
}
function fmtQtAmt(n) {
n = Math.abs(n || 0);
if (n >= 1e9) return (n/1e9 % 1 === 0 ? n/1e9 : (n/1e9).toFixed(1)) + "B";
if (n >= 1e6) return (n/1e6 % 1 === 0 ? n/1e6 : (n/1e6).toFixed(1)) + "M";
if (n >= 1e3) return Math.round(n/1e3) + "K";
return "$" + n;
}
function saveQtAmounts() { lsSet("qt_amounts", JSON.stringify(qtAmounts)); }
function renderQtAmounts() {
var grid = document.getElementById("qt-amounts");
if (!grid) return;
grid.innerHTML = "";
qtAmounts.forEach(function(amt, i) {
var btn = document.createElement("button");
var isActive = qtSelAmt === amt;
btn.style.cssText = "position:relative;padding:6px 9px;border-radius:7px;border:1px solid " +
(isActive ? (qtMode === "buy" ? "#4cff91" : "#ff4c6a") : "#2a2a4a") +
";background:" + (isActive ? (qtMode === "buy" ? "rgba(76,255,145,0.08)" : "rgba(255,76,106,0.08)") : "#13131f") +
";color:" + (isActive ? (qtMode === "buy" ? "#4cff91" : "#ff4c6a") : "#5a5a8a") +
";font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;";
btn.textContent = fmtQtAmt(amt);
if (qtEditMode) {
var del = document.createElement("span");
del.textContent = "✕";
del.style.cssText = "position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;background:#ff4c6a;color:#fff;font-size:9px;line-height:16px;text-align:center;cursor:pointer;";
del.onclick = function(e) {
e.stopPropagation();
qtAmounts.splice(i, 1);
if (qtSelAmt === amt) qtSelAmt = null;
saveQtAmounts(); renderQtAmounts(); qtUpdateExec();
};
btn.appendChild(del);
}
btn.onclick = function() {
if (qtEditMode) return;
qtSelAmt = (qtSelAmt === amt) ? null : amt;
renderQtAmounts(); qtUpdateExec();
};
grid.appendChild(btn);
});
if (qtEditMode) {
var addBtn = document.createElement("button");
addBtn.textContent = "+";
addBtn.style.cssText = "padding:6px 10px;border-radius:7px;border:1px dashed #2a2a4a;background:transparent;color:#6a6a9a;font-family:JetBrains Mono,monospace;font-size:14px;cursor:pointer;flex-shrink:0;";
addBtn.onclick = function() {
var val = prompt("Enter amount in $ (e.g. 25000000):");
val = parseInt(val, 10);
if (val > 0) { qtAmounts.push(val); qtAmounts.sort(function(a,b){return a-b;}); saveQtAmounts(); renderQtAmounts(); }
};
grid.appendChild(addBtn);
}
}
function qtUpdateExec() {
var btn = document.getElementById("qt-exec");
var stock = document.getElementById("qt-stock") ? document.getElementById("qt-stock").value : "";
if (!btn) return;
btn.disabled = false;
btn.style.background = qtMode === "buy" ? "rgba(76,255,145,0.15)" : "rgba(255,76,106,0.12)";
btn.style.color = qtMode === "buy" ? "#4cff91" : "#ff4c6a";
btn.style.borderColor = qtMode === "buy" ? "rgba(76,255,145,0.25)" : "rgba(255,76,106,0.25)";
btn.style.opacity = (!stock || !qtSelAmt) ? "0.4" : "1";
btn.textContent = (qtMode === "buy" ? "▲ Buy " : "▼ Sell ") +
(qtSelAmt ? fmtQtAmt(qtSelAmt) : "?") +
(stock ? " of " + stock : " — select stock");
// Swing shares available label (only when lock is on + stock is a benefit stock)
var swingLabel = document.getElementById("qt-swing-available");
var swingInfo = document.getElementById("qt-benefit-info");
if (swingLabel && swingInfo) {
var lockOn = document.getElementById("qt-lock-benefit") && document.getElementById("qt-lock-benefit").checked;
var maxSellDisp = (lockOn && stock) ? qtBenefitLockMax(stock) : Infinity;
if (lockOn && stock && maxSellDisp !== Infinity) {
var dispText = maxSellDisp === -1 ? "Cannot read share count"
: maxSellDisp === 0 ? "No swing shares — fully locked"
: maxSellDisp.toLocaleString("en-US") + " swing shares available to sell";
swingLabel.textContent = "🔒 " + dispText;
swingInfo.style.display = "block";
} else {
swingInfo.style.display = "none";
}
}
}
function qtExecuteBuy(sym, dollarAmt) {
qtBuildMaps();
if (!sym) { showToast("Select a stock first.", "warn"); return; }
var price = qtGetPrice(sym);
if (price <= 0) { showToast("Could not read price for " + sym, "error"); return; }
var amt = Math.floor(dollarAmt / price);
if (amt <= 0) { showToast("Amount too small.", "warn"); return; }
qtPostTrade(sym, amt, "buyShares", "Bought " + amt.toLocaleString() + " " + sym);
}
function qtExecuteSell(sym, dollarAmt) {
qtBuildMaps();
if (!sym) { showToast("Select a stock first.", "warn"); return; }
var price = qtGetPrice(sym);
if (price <= 0) { showToast("Could not read price for " + sym, "error"); return; }
var shares = Math.ceil((dollarAmt / 0.999) / price);
if ($("#qt-lock-benefit").is(":checked")) {
var maxSell = qtBenefitLockMax(sym);
if (maxSell === -1) { showToast("Benefit Lock: Cannot verify share count — blocked for safety", "warn"); return; }
if (maxSell === 0) { showToast("Benefit Lock: All shares are benefit block shares — cannot sell", "warn"); return; }
if (maxSell !== Infinity && shares > maxSell) {
shares = maxSell;
showToast("Benefit Lock: Capped to " + maxSell.toLocaleString() + " swing shares", "warn");
}
}
qtPostTrade(sym, shares, "sellShares", "Sold " + shares.toLocaleString() + " " + sym);
}
function createAmountBtn(label, amt, mode, idx) {
var btn = document.createElement("button");
var isBuy = mode === "buy";
var isDark = lsGet("tsa_dark", "false") === "true";
var buyBorder = isDark ? "rgba(76,255,145,0.3)" : "#1a8a45";
var buyBg = isDark ? "rgba(76,255,145,0.08)" : "rgba(26,138,69,0.08)";
var buyColor = isDark ? "#4cff91" : "#1a8a45";
var sellBorder = isDark ? "rgba(255,76,106,0.3)" : "#cc2222";
var sellBg = isDark ? "rgba(255,76,106,0.08)" : "rgba(204,34,34,0.08)";
var sellColor = isDark ? "#ff4c6a" : "#cc2222";
btn.style.cssText = "position:relative;padding:6px 9px;border-radius:7px;border:1px solid " +
(isBuy ? buyBorder : sellBorder) +
";background:" + (isBuy ? buyBg : sellBg) +
";color:" + (isBuy ? buyColor : sellColor) +
";font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;";
btn.textContent = label;
if (qtEditMode) {
// Del button
var del = document.createElement("span");
del.textContent = "✕";
del.style.cssText = "position:absolute;top:-6px;right:-6px;width:16px;height:16px;border-radius:50%;background:#ff4c6a;color:#fff;font-size:9px;line-height:16px;text-align:center;cursor:pointer;";
del.onclick = function(e) {
e.stopPropagation();
qtAmounts.splice(idx, 1);
saveQtAmounts(); renderQtRows();
};
btn.appendChild(del);
// Click to edit value
btn.onclick = function() {
var newVal = prompt("Edit amount:", amt);
newVal = parseInt(newVal, 10);
if (newVal > 0) {
qtAmounts[idx] = newVal;
qtAmounts.sort(function(a,b){return a-b;});
saveQtAmounts(); renderQtRows();
}
};
} else {
btn.onclick = function() {
var s = $("#qt-stock").val();
if (!s) { showToast("Select a stock first.", "warn"); return; }
if (isBuy) qtExecuteBuy(s, amt);
else qtExecuteSell(s, amt);
};
}
return btn;
}
function renderQtRows() {
var buyRow = document.getElementById("qt-buy-row");
var sellRow = document.getElementById("qt-sell-row");
if (!buyRow || !sellRow) return;
buyRow.innerHTML = "";
sellRow.innerHTML = "";
qtAmounts.forEach(function(amt, idx) {
buyRow.appendChild(createAmountBtn(fmtQtAmt(amt), amt, "buy", idx));
sellRow.appendChild(createAmountBtn(fmtQtAmt(amt), amt, "sell", idx));
});
// ALL buy btn
var allBuyBtn = document.createElement("button");
var isDarkNow = lsGet("tsa_dark", "false") === "true";
allBuyBtn.style.cssText = "padding:6px 9px;border-radius:7px;border:1px solid " + (isDarkNow ? "rgba(76,255,145,0.5)" : "#1a8a45") + ";background:" + (isDarkNow ? "rgba(76,255,145,0.15)" : "rgba(26,138,69,0.1)") + ";color:" + (isDarkNow ? "#4cff91" : "#1a8a45") + ";font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;";
allBuyBtn.textContent = "ALL";
allBuyBtn.onclick = function() {
var s = $("#qt-stock").val();
if (!s) { showToast("Select a stock first.", "warn"); return; }
qtBuildMaps(); qtVault(s);
};
buyRow.appendChild(allBuyBtn);
// ALL sell btn
var allSellBtn = document.createElement("button");
allSellBtn.style.cssText = "padding:6px 9px;border-radius:7px;border:1px solid " + (isDarkNow ? "rgba(255,76,106,0.5)" : "#cc2222") + ";background:" + (isDarkNow ? "rgba(255,76,106,0.12)" : "rgba(204,34,34,0.08)") + ";color:" + (isDarkNow ? "#ff4c6a" : "#cc2222") + ";font-family:JetBrains Mono,monospace;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;";
allSellBtn.textContent = "ALL";
allSellBtn.onclick = function() {
var s = $("#qt-stock").val();
if (!s) { showToast("Select a stock first.", "warn"); return; }
qtBuildMaps(); qtWithdrawAll(s);
};
sellRow.appendChild(allSellBtn);
if (qtEditMode) {
var addBtn = document.createElement("button");
addBtn.style.cssText = "padding:6px 10px;border-radius:7px;border:1px dashed #2a2a4a;background:transparent;color:#4a6fa5;font-family:JetBrains Mono,monospace;font-size:14px;cursor:pointer;flex-shrink:0;";
addBtn.textContent = "+";
addBtn.onclick = function() {
var val = prompt("Enter amount in $ (e.g. 25000000):");
val = parseInt(val, 10);
if (val > 0) { qtAmounts.push(val); qtAmounts.sort(function(a,b){return a-b;}); saveQtAmounts(); renderQtRows(); }
};
buyRow.appendChild(addBtn);
}
}
function qtDrawChart(sym) {
var container = document.getElementById("qt-chart-container");
var canvas = document.getElementById("qt-chart-canvas");
var title = document.getElementById("qt-chart-title");
var liveEl = document.getElementById("qt-chart-live");
var labelsEl = document.getElementById("qt-chart-labels");
if (!container || !canvas) return;
if (!sym) { container.style.display = "none"; return; }
var p_live = 0;
if (lastRaw) {
var r = lastRaw.find(function(x) { return x.stock === sym.toUpperCase(); });
if (r) p_live = parseFloat(r.price) || 0;
}
// Load ALL stored history for this stock
var history = loadHistory();
var points = (history[sym.toUpperCase()] || []).map(function(h) {
return { ts: h.ts, price: h.price };
});
// Add live price as final point if it differs from last stored
var now = Date.now();
if (p_live > 0) {
var liveTs = Math.round(now / 60000) * 60000;
// Remove any duplicate ts at this minute, then push fresh live
points = points.filter(function(p) { return p.ts !== liveTs; });
points.push({ ts: liveTs, price: p_live });
}
// Sort chronologically
points.sort(function(a, b) { return a.ts - b.ts; });
// Apply selected timeframe filter
(function() {
var tf = lsGet("tsa_chart_timeframe", "all");
var tfMs = { "1d": 86400000, "3d": 259200000, "7d": 604800000 }[tf];
if (tfMs) {
var cutoff = Date.now() - tfMs;
points = points.filter(function(p) { return p.ts >= cutoff; });
}
// Highlight active button
var btns = document.querySelectorAll(".qt-tf-btn");
btns.forEach(function(b) {
var active = b.getAttribute("data-tf") === tf;
b.style.background = active ? "#2a2a4a" : "none";
b.style.color = active ? "#e0e0ff" : "#7a7a9a";
});
})();
// ── Outlier filter: drop points > 12% away from local 5-pt median ──────
if (points.length >= 5) {
points = points.filter(function(pt, i, arr) {
var win = [];
for (var j = Math.max(0, i - 2); j <= Math.min(arr.length - 1, i + 2); j++) {
if (j !== i) win.push(arr[j].price);
}
if (!win.length) return true;
win.sort(function(a, b) { return a - b; });
var med = win[Math.floor(win.length / 2)];
return med <= 0 || Math.abs(pt.price - med) / med < 0.12;
});
}
// ── Bucket-downsample: 1 median price per 30-min slot ───────────────────
// (collapses clustered points that cause vertical spike artifacts)
(function() {
var BUCKET = 30 * 60000;
var map = {};
points.forEach(function(pt) {
var k = Math.floor(pt.ts / BUCKET);
if (!map[k]) map[k] = [];
map[k].push(pt.price);
});
var keys = Object.keys(map).map(Number).sort(function(a, b) { return a - b; });
points = keys.map(function(k) {
var arr = map[k].slice().sort(function(a, b) { return a - b; });
return { ts: k * BUCKET + BUCKET / 2, price: arr[Math.floor(arr.length / 2)] };
});
})();
if (points.length < 2) {
container.style.display = "block";
canvas.style.display = "none";
var noDataEl = document.getElementById("qt-chart-nodata");
if (!noDataEl) {
noDataEl = document.createElement("div");
noDataEl.id = "qt-chart-nodata";
noDataEl.style.cssText = "padding:18px;text-align:center;font-size:11px;color:#888";
container.appendChild(noDataEl);
}
noDataEl.style.display = "block";
noDataEl.textContent = "Not enough history for " + sym + " — check back after next refresh";
return;
}
canvas.style.display = "";
var noDataEl2 = document.getElementById("qt-chart-nodata");
if (noDataEl2) noDataEl2.style.display = "none";
container.style.display = "block";
if (title) title.textContent = sym + " · " + points.length + " pts · 7d";
if (liveEl) liveEl.textContent = p_live > 0 ? "$" + p_live.toFixed(2) : "";
// Canvas — taller to show more detail, full width
var w = canvas.offsetWidth || 300;
var h = 110;
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
var prices = points.map(function(p) { return p.price; });
var minP = Math.min.apply(null, prices);
var maxP = Math.max.apply(null, prices);
var range = maxP - minP || maxP * 0.001 || 1;
var padT = 14, padB = 10, padL = 2, padR = 2;
var chartH = h - padT - padB;
var chartW = w - padL - padR;
// Time range for proportional x positioning
var tMin = points[0].ts;
var tMax = points[points.length - 1].ts;
var tRange = tMax - tMin || 1;
function xOf(ts) { return padL + ((ts - tMin) / tRange) * chartW; }
function yOf(price) { return padT + (1 - (price - minP) / range) * chartH; }
// Grid — 4 horizontal lines
ctx.strokeStyle = "rgba(60,60,100,0.25)";
ctx.lineWidth = 0.5;
for (var g = 0; g <= 3; g++) {
var gy = padT + chartH * g / 3;
ctx.beginPath(); ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy); ctx.stroke();
}
// Recalculate scales after filtering/downsampling
if (points.length < 2) return; // guard: filtering may have removed too many
prices = points.map(function(p) { return p.price; });
minP = Math.min.apply(null, prices);
maxP = Math.max.apply(null, prices);
// Add 4% padding so line never touches the very top/bottom edge
var pricePad = (maxP - minP) * 0.04 || maxP * 0.004 || 0.01;
minP -= pricePad; maxP += pricePad;
range = maxP - minP || 1;
// Re-anchor time scale to downsampled timestamps
tMin = points[0].ts;
tMax = points[points.length - 1].ts;
tRange = tMax - tMin || 1;
// Trend color
var isUp = prices[prices.length - 1] >= prices[0];
var lineColor = isUp ? "#4cff91" : "#ff4c6a";
var fillColor = isUp ? "rgba(76,255,145,0.07)" : "rgba(255,76,106,0.06)";
// Gap threshold: segments more than 4 h apart are not connected
var GAP = 4 * 3600000;
// Split into continuous segments respecting gaps
var segments = [];
var seg = [];
for (var si = 0; si < points.length; si++) {
if (seg.length > 0 && points[si].ts - points[si - 1].ts > GAP) {
if (seg.length >= 2) segments.push(seg);
seg = [];
}
seg.push(points[si]);
}
if (seg.length >= 2) segments.push(seg);
// Helper: draw smooth open path through segment using midpoint curves
function drawSmoothPath(pts) {
var x0 = xOf(pts[0].ts), y0 = yOf(pts[0].price);
ctx.moveTo(x0, y0);
for (var i = 1; i < pts.length; i++) {
var x1 = xOf(pts[i].ts), y1 = yOf(pts[i].price);
var mx = (xOf(pts[i - 1].ts) + x1) / 2;
var my = (yOf(pts[i - 1].price) + y1) / 2;
ctx.quadraticCurveTo(xOf(pts[i - 1].ts), yOf(pts[i - 1].price), mx, my);
}
// End exactly at last point
ctx.lineTo(xOf(pts[pts.length - 1].ts), yOf(pts[pts.length - 1].price));
}
// Fill under each segment
segments.forEach(function(pts) {
ctx.beginPath();
drawSmoothPath(pts);
ctx.lineTo(xOf(pts[pts.length - 1].ts), h - padB);
ctx.lineTo(xOf(pts[0].ts), h - padB);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
});
// Price line over each segment
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.lineCap = "round";
segments.forEach(function(pts) {
ctx.beginPath();
ctx.strokeStyle = lineColor;
drawSmoothPath(pts);
ctx.stroke();
});
// Buy markers (green ▲) from transaction history
var ownedForChart = lastOwnedMap && lastOwnedMap[sym.toUpperCase()];
if (ownedForChart && ownedForChart.transactions) {
ownedForChart.transactions.forEach(function(t) {
if (!t.bought_price || t.bought_price <= 0) return;
var txMs = (t.time_bought || 0) * 1000;
if (txMs < tMin || txMs > tMax) return;
var x = xOf(txMs), y = yOf(t.bought_price);
ctx.beginPath();
ctx.moveTo(x, y - 5);
ctx.lineTo(x - 4, y + 3);
ctx.lineTo(x + 4, y + 3);
ctx.closePath();
ctx.fillStyle = "#4cff91";
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
});
}
// Sell markers (red ▼) from realized events
var chartSellEvents = getRealizedEvents().filter(function(e) {
return e.sym === sym.toUpperCase() && e.sell_price > 0;
});
chartSellEvents.forEach(function(e) {
var evMs = e.ts * 1000;
if (evMs < tMin || evMs > tMax) return;
var x = xOf(evMs), y = yOf(e.sell_price);
ctx.beginPath();
ctx.moveTo(x, y + 5);
ctx.lineTo(x - 4, y - 3);
ctx.lineTo(x + 4, y - 3);
ctx.closePath();
ctx.fillStyle = "#ff4c6a";
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
});
// Min/Max price labels top-right (use actual data extremes, not padded)
var dataMax = Math.max.apply(null, prices);
var dataMin = Math.min.apply(null, prices);
ctx.fillStyle = "#7a7a9a";
ctx.font = "8px JetBrains Mono, monospace";
ctx.textAlign = "right";
ctx.fillText("$" + dataMax.toFixed(2), w - padR - 1, padT - 3);
ctx.fillText("$" + dataMin.toFixed(2), w - padR - 1, h - padB + 8);
// Time labels below — first, 25%, 50%, 75%, last
if (labelsEl) {
function fmtTs(ts) {
var d = new Date(ts);
var now2 = Date.now();
var age = now2 - ts;
if (age < 3600000) return Math.round(age / 60000) + "m";
if (age < 86400000) return Math.round(age / 3600000) + "h";
return d.getDate() + "/" + (d.getMonth() + 1);
}
var picks = [0, Math.floor(points.length * 0.25), Math.floor(points.length * 0.5), Math.floor(points.length * 0.75), points.length - 1];
// Deduplicate picks
picks = picks.filter(function(v, i, a) { return a.indexOf(v) === i; });
labelsEl.style.position = "relative";
labelsEl.innerHTML = picks.map(function(idx) {
var pt = points[idx];
var xPct = ((pt.ts - tMin) / tRange * 100).toFixed(1);
var isLast = idx === points.length - 1;
return '<span style="position:absolute;left:' + xPct + '%;transform:translateX(-50%);' + (isLast ? 'color:#4cff91' : '') + '">' +
(isLast ? 'Now' : fmtTs(pt.ts)) + '</span>';
}).join('');
}
// Hover tooltip
(function() {
var tip = document.getElementById("qt-chart-tip");
if (!tip) {
tip = document.createElement("div");
tip.id = "qt-chart-tip";
tip.style.cssText = "position:absolute;pointer-events:none;display:none;background:rgba(10,10,20,0.92);border:1px solid #3a3a6a;border-radius:6px;padding:5px 8px;font-family:JetBrains Mono,monospace;font-size:10px;color:#e0e0ff;z-index:9999;white-space:nowrap;";
container.style.position = "relative";
container.appendChild(tip);
}
canvas.onmousemove = function(ev) {
var rect = canvas.getBoundingClientRect();
var mx = ev.clientX - rect.left;
var scaleX = canvas.width / rect.width;
var px = mx * scaleX;
// Find nearest point
var best = null, bestDist = Infinity;
points.forEach(function(pt) {
var x = padL + ((pt.ts - tMin) / tRange) * chartW;
var dist = Math.abs(x - px);
if (dist < bestDist) { bestDist = dist; best = pt; }
});
if (!best) { tip.style.display = "none"; return; }
var tipX = mx + 10;
if (tipX + 120 > rect.width) tipX = mx - 130;
tip.style.left = tipX + "px";
tip.style.top = "4px";
tip.style.display = "block";
var d2 = new Date(best.ts);
var timeStr = d2.getDate() + "/" + (d2.getMonth()+1) + " " +
("0"+d2.getHours()).slice(-2) + ":" + ("0"+d2.getMinutes()).slice(-2);
tip.textContent = "$" + best.price.toFixed(2) + " " + timeStr;
};
canvas.onmouseleave = function() { tip.style.display = "none"; };
})();
}
function applyQtTheme(isDark) {
var bar = document.getElementById("qt-bar");
if (!bar) return;
var bg = isDark ? "#0c0c14" : "#f0f4ff";
var border = isDark ? "#3a3a6a" : "#c0d0ff";
var selBg = isDark ? "#13131f" : "#ffffff";
var selColor = isDark ? "#e0e0ff" : "#222222";
var selBord = isDark ? "#2a2a4a" : "#c0d0ee";
var chartBg = isDark ? "#0a0a12" : "#f7f9fc";
var chartBrd = isDark ? "#2a2a4a" : "#dde3f0";
var labelCol = isDark ? "#7a7a9a" : "#666666";
var liveCol = isDark ? "#e0e0ff" : "#222222";
bar.style.cssText = bar.style.cssText
.replace(/background:[^;]+/, "background:" + bg)
.replace(/border-bottom:[^;]+/, "border-bottom:2px solid " + border);
var chart = document.getElementById("qt-chart-container");
if (chart) {
chart.style.background = chartBg;
chart.style.border = "1px solid " + chartBrd;
}
var sel = document.getElementById("qt-stock-search");
if (sel) {
sel.style.background = selBg;
sel.style.color = selColor;
sel.style.border = "1px solid " + selBord;
}
var listEl = document.getElementById("qt-stock-list");
if (listEl) {
listEl.style.background = selBg;
listEl.style.border = "1px solid " + selBord;
}
var titleEl = document.getElementById("qt-chart-title");
if (titleEl) titleEl.style.color = labelCol;
var liveEl = document.getElementById("qt-chart-live");
if (liveEl) liveEl.style.color = liveCol;
var labelsEl = document.getElementById("qt-chart-labels");
if (labelsEl) labelsEl.style.color = labelCol;
// Benefit Lock label — yellow is invisible on light backgrounds
var lockLabel = document.getElementById("qt-lock-label");
var lockText = document.getElementById("qt-lock-text");
if (lockLabel) {
lockLabel.style.border = isDark ? "1px solid rgba(255,193,7,0.35)" : "1px solid #c8930a";
lockLabel.style.background = isDark ? "rgba(255,193,7,0.08)" : "rgba(180,120,0,0.08)";
}
if (lockText) {
lockText.style.color = isDark ? "#ffc107" : "#8a5c00";
}
var minBtn = document.getElementById("qt-min-btn");
if (minBtn) {
minBtn.style.color = isDark ? "#6a6a9a" : "#555577";
minBtn.style.borderColor = isDark ? "#2a2a4a" : "#c0d0ee";
}
// Re-render buttons with new theme colors
renderQtRows();
}
function createQuickTradeBar() {
var bar = document.createElement("div");
bar.id = "qt-bar";
bar.style.cssText = "background:#0c0c14;border-bottom:2px solid #3a3a6a;padding:8px 12px;box-shadow:0 4px 16px rgba(0,0,0,0.5);font-family:JetBrains Mono,monospace;position:sticky;top:0;z-index:9999;";
bar.innerHTML =
// Row 1: Minimize button + Stock searchable combobox + edit + lock
"<div style='display:flex;gap:7px;margin-bottom:6px;align-items:center'>" +
"<button id='qt-min-btn' title='Minimer Quick Trade bar' style='padding:4px 7px;border-radius:7px;border:1px solid #2a2a4a;background:transparent;color:#6a6a9a;font-family:JetBrains Mono,monospace;font-size:10px;cursor:pointer;flex-shrink:0;'>▼</button>" +
"<div id='qt-stock-wrap' style='position:relative;flex:1'>" +
"<input id='qt-stock-search' type='text' placeholder='Search stock…' autocomplete='off' style='width:100%;box-sizing:border-box;background:#13131f;border:1px solid #2a2a4a;border-radius:7px;color:#e0e0ff;font-family:JetBrains Mono,monospace;font-size:12px;font-weight:700;padding:7px 26px 7px 10px;outline:none;'>" +
"<input type='hidden' id='qt-stock' value=''>" +
"<span style='position:absolute;right:9px;top:50%;transform:translateY(-50%);color:#6a6a9a;font-size:9px;pointer-events:none'>▼</span>" +
"<div id='qt-stock-list' style='display:none;position:absolute;top:calc(100% + 3px);left:0;right:0;background:#13131f;border:1px solid #2a2a4a;border-radius:7px;z-index:99999;max-height:200px;overflow-y:auto;box-shadow:0 4px 16px rgba(0,0,0,0.5);'></div>" +
"</div>" +
"<button id='qt-edit' title='Edit trade amounts' style='padding:6px 8px;border-radius:7px;border:1px solid #2a2a4a;background:transparent;color:#6a6a9a;font-family:JetBrains Mono,monospace;font-size:10px;cursor:pointer;flex-shrink:0;'>✎</button>" +
"<label id='qt-lock-label' style='display:flex;align-items:center;gap:5px;cursor:pointer;flex-shrink:0;padding:4px 8px;border-radius:7px;border:1px solid rgba(255,193,7,0.35);background:rgba(255,193,7,0.08);'>" +
"<input type='checkbox' id='qt-lock-benefit' checked style='accent-color:#ffc107;width:13px;height:13px;cursor:pointer'>" +
"<span id='qt-lock-text' style='font-size:10px;font-weight:700;color:#ffc107;font-family:JetBrains Mono,monospace;letter-spacing:0.04em;white-space:nowrap;'>🔒 Benefit Lock</span>" +
"</label>" +
"</div>" +
// Collapsible body: benefit info + buy/sell rows + rec + chart
"<div id='qt-body'>" +
"<div id='qt-benefit-info' style='display:none;margin-bottom:4px;padding:0 2px;'>" +
"<span id='qt-swing-available' style='font-size:10px;color:#ffc107;font-family:JetBrains Mono,monospace;font-weight:600;'></span>" +
"</div>" +
// Row 2: BUY buttons
"<div style='display:flex;gap:5px;align-items:center;margin-bottom:5px'>" +
"<span style='font-size:9px;font-weight:700;color:#4cff91;font-family:JetBrains Mono,monospace;flex-shrink:0;letter-spacing:0.06em;'>▲</span>" +
"<div id='qt-buy-row' style='display:flex;gap:5px;flex:1;overflow-x:auto;padding-bottom:2px'></div>" +
"</div>" +
// Row 3: SELL buttons
"<div style='display:flex;gap:5px;align-items:center'>" +
"<span style='font-size:9px;font-weight:700;color:#ff4c6a;font-family:JetBrains Mono,monospace;flex-shrink:0;letter-spacing:0.06em;'>▼</span>" +
"<div id='qt-sell-row' style='display:flex;gap:5px;flex:1;overflow-x:auto;padding-bottom:2px'></div>" +
"</div>" +
// ROI recommendation
"<div id='qt-rec' style='display:none;margin-top:6px;'></div>" +
// Chart
"<div id='qt-chart-container' style='display:none;margin-top:8px;background:#0a0a12;border-radius:8px;border:1px solid #2a2a4a;padding:8px 10px 6px;'>" +
"<div style='display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;'>" +
"<span id='qt-chart-title' style='font-size:9px;font-weight:700;color:#7a7a9a;font-family:JetBrains Mono,monospace;letter-spacing:0.08em;text-transform:uppercase;'></span>" +
"<span id='qt-chart-live' style='font-size:10px;font-weight:700;font-family:JetBrains Mono,monospace;color:#e0e0ff;'></span>" +
"</div>" +
"<canvas id='qt-chart-canvas' height='110' style='width:100%;height:110px;display:block;'></canvas>" +
"<div id='qt-chart-labels' style='position:relative;height:14px;font-size:8px;color:#6a6a9a;font-family:JetBrains Mono,monospace;margin-top:2px;'></div>" +
"<div id='qt-tf-row' style='display:flex;gap:3px;margin-top:5px;'>" +
"<button class='qt-tf-btn' data-tf='1d' style='flex:1;padding:2px 0;border-radius:4px;border:1px solid #2a2a4a;background:none;color:#7a7a9a;font-size:9px;font-family:JetBrains Mono,monospace;cursor:pointer;'>1d</button>" +
"<button class='qt-tf-btn' data-tf='3d' style='flex:1;padding:2px 0;border-radius:4px;border:1px solid #2a2a4a;background:none;color:#7a7a9a;font-size:9px;font-family:JetBrains Mono,monospace;cursor:pointer;'>3d</button>" +
"<button class='qt-tf-btn' data-tf='7d' style='flex:1;padding:2px 0;border-radius:4px;border:1px solid #2a2a4a;background:none;color:#7a7a9a;font-size:9px;font-family:JetBrains Mono,monospace;cursor:pointer;'>7d</button>" +
"<button class='qt-tf-btn' data-tf='all' style='flex:1;padding:2px 0;border-radius:4px;border:1px solid #2a2a4a;background:none;color:#7a7a9a;font-size:9px;font-family:JetBrains Mono,monospace;cursor:pointer;'>All</button>" +
"</div>" +
"</div>" +
"</div>";
var target = document.getElementById("stockmarketroot") ||
document.querySelector(".content-wrapper") ||
document.querySelector("#page-wrapper > div") ||
document.body;
if (target && target.firstChild) target.insertBefore(bar, target.firstChild);
else document.body.insertBefore(bar, document.body.firstChild);
renderQtRows();
setTimeout(qtBuildMaps, 1000);
setTimeout(function() { updateQtRecommendation(null); }, 1500);
// Apply initial theme
applyQtTheme(lsGet("tsa_dark", "false") === "true");
// Apply initial minimized state
(function() {
var minimized = lsGet("qt_minimized", "false") === "true";
var qtBody = document.getElementById("qt-body");
var qtMinBtn = document.getElementById("qt-min-btn");
if (qtBody) qtBody.style.display = minimized ? "none" : "block";
if (qtMinBtn) qtMinBtn.textContent = minimized ? "\u25B6" : "\u25BC";
})();
// Timeframe button click handlers
document.querySelectorAll(".qt-tf-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
lsSet("tsa_chart_timeframe", this.getAttribute("data-tf"));
var sym = lsGet("qt_last_stock", "");
if (sym) qtDrawChart(sym);
});
});
// Searchable combobox logic
(function() {
var searchEl = document.getElementById("qt-stock-search");
var hiddenEl = document.getElementById("qt-stock");
var listEl = document.getElementById("qt-stock-list");
if (!searchEl || !hiddenEl || !listEl) return;
function buildList(filter) {
var f = (filter || "").toUpperCase().trim();
var items = f ? QT_STOCKS.filter(function(s){ return s.indexOf(f) >= 0; }) : QT_STOCKS;
var isDarkList = lsGet("tsa_dark", "false") === "true";
listEl.innerHTML = "";
items.forEach(function(sym) {
var item = document.createElement("div");
item.className = "qt-stock-list-item";
item.textContent = sym;
item.style.color = isDarkList ? "#e0e0ff" : "#222222";
item.onmousedown = function(e) {
e.preventDefault(); // prevent blur before click fires
hiddenEl.value = sym;
searchEl.value = sym;
listEl.style.display = "none";
lsSet("qt_last_stock", sym);
qtDrawChart(sym);
qtUpdateExec();
};
listEl.appendChild(item);
});
listEl.style.display = items.length ? "block" : "none";
}
searchEl.addEventListener("focus", function() { this.select(); buildList(""); });
searchEl.addEventListener("input", function() {
hiddenEl.value = "";
buildList(searchEl.value);
qtUpdateExec();
});
searchEl.addEventListener("blur", function() {
// Small delay so mousedown on item fires first
setTimeout(function() { listEl.style.display = "none"; }, 150);
});
searchEl.addEventListener("keydown", function(e) {
if (e.key === "Escape") { listEl.style.display = "none"; searchEl.blur(); }
});
// Restore last selected stock
var lastStock = lsGet("qt_last_stock", "");
if (lastStock) {
hiddenEl.value = lastStock;
searchEl.value = lastStock;
qtDrawChart(lastStock);
}
})();
$("#qt-edit").on("click", function() {
qtEditMode = !qtEditMode;
$(this).css({color: qtEditMode ? "#4a6fa5" : "#6a6a9a", borderColor: qtEditMode ? "#4a6fa5" : "#2a2a4a"});
$(this).text(qtEditMode ? "✓" : "✎");
renderQtRows();
});
document.getElementById("qt-lock-benefit") && document.getElementById("qt-lock-benefit").addEventListener("change", function() {
qtUpdateExec();
});
document.getElementById("qt-min-btn") && document.getElementById("qt-min-btn").addEventListener("click", function() {
var minimized = lsGet("qt_minimized", "false") === "true";
minimized = !minimized;
lsSet("qt_minimized", String(minimized));
var body = document.getElementById("qt-body");
if (body) body.style.display = minimized ? "none" : "block";
this.textContent = minimized ? "\u25B6" : "\u25BC";
});
}
function createUI() {
injectStyles();
// Extra CSS for v1.8.0 UI features
var extraStyle = document.createElement("style");
extraStyle.textContent = [
"@keyframes tsaToastIn{from{opacity:0;transform:translateX(-20px)}to{opacity:1;transform:translateX(0)}}",
"@keyframes tsaSlideIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}",
"#tsa-overlay.tsa-visible{animation:tsaSlideIn 0.18s ease forwards}",
".tsa-pin-btn{background:none;border:none;cursor:pointer;font-size:12px;padding:0 2px;opacity:0.35;transition:opacity 0.15s;line-height:1;vertical-align:middle}",
".tsa-pin-btn.pinned{opacity:1}",
".tsa-pin-btn:hover{opacity:1}",
"#tsa-scroll-top{position:sticky;bottom:8px;float:right;margin-right:8px;z-index:10;background:#4a6fa5;color:#fff;border:none;border-radius:50%;width:26px;height:26px;font-size:13px;cursor:pointer;display:none;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.35);line-height:1}",
"#tsa-scroll-top:hover{background:#3a5f95}",
"#tsa-overlay.tsa-dark #tsa-scroll-top{background:#2a2a5a;color:#a0a0ff}",
".qt-stock-list-item{padding:7px 10px;cursor:pointer;font-size:12px;font-weight:700;font-family:JetBrains Mono,monospace}",
".qt-stock-list-item:hover{background:rgba(122,159,212,0.15)}"
].join("\n");
document.head.appendChild(extraStyle);
// Create Quick Trade bar embedded in page
createQuickTradeBar();
var btn = document.createElement("button");
btn.id = "tsa-btn";
btn.textContent = "Stocks";
document.body.appendChild(btn);
var overlay = document.createElement("div");
overlay.id = "tsa-overlay";
// Dynamic width: mobile uses available screen width, desktop capped at 420px
var isMobile = /Mobi|Android/i.test(navigator.userAgent);
overlay.style.width = isMobile ? (window.innerWidth - 32) + "px" : "420px";
if (lsGet("tsa_dark", "false") === "true") overlay.classList.add("tsa-dark");
var isDarkInit = lsGet("tsa_dark", "false") === "true";
overlay.innerHTML =
"<div class=\"tsa-header\">" +
"<div class=\"tsa-header-left\">" +
"<span class=\"tsa-title\">TORN STOCK ANALYZER</span>" +
"<button class=\"tsa-theme-btn\" id=\"tsa-theme-btn\" title=\"Toggle theme\">" + (isDarkInit ? "[L]" : "[D]") + "</button>" +
"<button class=\"tsa-theme-btn\" id=\"tsa-roi-btn\" title=\"ROI Planner\">📊</button>" +
"<button class=\"tsa-theme-btn\" id=\"tsa-alerts-btn\" title=\"Price Alerts\">🔔</button>" +
"<button class=\"tsa-theme-btn\" id=\"tsa-apikey-btn\" title=\"Set API Key\">🔑</button>" +
"<button class=\"tsa-theme-btn\" id=\"tsa-settings-btn\" title=\"Settings\">⚙️</button>" +
"<button class=\"tsa-theme-btn\" id=\"tsa-update-btn\" title=\"Update all\">↻</button>" +
"</div>" +
"<span class=\"tsa-close\" id=\"tsa-close\">x</span>" +
"</div>" +
"<div id=\"tsa-content\">" +
"<div class=\"tsa-loading\">Ready to analyze</div>" +
"<div class=\"tsa-footer\"><span></span><button class=\"tsa-refresh\" id=\"tsa-init-btn\">Start</button></div>" +
"</div>";
document.body.appendChild(overlay);
applyOverlayPosition(getOverlayPosition());
document.getElementById("tsa-theme-btn").addEventListener("click", function() {
var isDark = overlay.classList.toggle("tsa-dark");
lsSet("tsa_dark", isDark.toString());
document.getElementById("tsa-theme-btn").textContent = isDark ? "[L]" : "[D]";
// Sync quick trade bar theme
applyQtTheme(isDark);
// Re-render content with new colours
if (roiPlannerActive && lastOwnedMap) {
showROIPlanner(lastOwnedMap, lastRaw);
} else if (lastOwnedMap) {
loadData();
}
});
document.getElementById("tsa-apikey-btn").addEventListener("click", function() {
var content = document.getElementById("tsa-content");
showKeyOnboarding(content, function() {
showToast("API key saved.", "success");
loadData();
});
});
document.getElementById("tsa-settings-btn").addEventListener("click", function() {
var content = document.getElementById("tsa-content");
var isDarkNow = overlay.classList.contains("tsa-dark");
var bg = isDarkNow ? "#0f0f1a" : "#ffffff";
var bg2 = isDarkNow ? "#1a1a2e" : "#f7f9fc";
var border = isDarkNow ? "#2a2a4a" : "#eee";
var text = isDarkNow ? "#c8c8d8" : "#222";
var muted = isDarkNow ? "#7a7a9a" : "#666";
content.innerHTML =
"<div style=\"padding:14px\">" +
"<div style=\"font-size:10px;letter-spacing:0.12em;color:" + muted + ";text-transform:uppercase;font-weight:bold;margin-bottom:14px\">Settings</div>" +
"<div style=\"margin-bottom:12px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Profit target (%)</div>" +
"<input id=\"tsa-setting-profit\" type=\"number\" step=\"0.1\" min=\"0.1\" max=\"10\" value=\"" + getProfitTarget() + "\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;\">" +
"</div>" +
"<div style=\"margin-bottom:12px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Stop loss (%)</div>" +
"<input id=\"tsa-setting-stoploss\" type=\"number\" step=\"0.1\" min=\"0.1\" max=\"20\" value=\"" + getStopLoss() + "\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;\">" +
"</div>" +
"<div style=\"margin-bottom:12px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Auto-refresh (min, 0 = off)</div>" +
"<input id=\"tsa-setting-autorefresh\" type=\"number\" step=\"1\" min=\"0\" max=\"60\" value=\"" + getAutoRefreshInterval() + "\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;\">" +
"</div>" +
"<div style=\"margin-bottom:16px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Price history (days, 1–30)</div>" +
"<input id=\"tsa-setting-histdays\" type=\"number\" step=\"1\" min=\"1\" max=\"30\" value=\"" + parseInt(lsGet("tsa_history_days", "30"), 10) + "\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;\">" +
"</div>" +
"<div style=\"border-top:1px solid " + border + ";margin-bottom:12px;padding-top:12px\">" +
"<div style=\"font-size:10px;letter-spacing:0.1em;color:" + muted + ";text-transform:uppercase;font-weight:bold;margin-bottom:10px\">Buy signals</div>" +
"<label style=\"display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:10px\">" +
"<input type=\"checkbox\" id=\"tsa-setting-show-watch\"" + (getShowWatch() ? " checked" : "") + " style=\"width:15px;height:15px;cursor:pointer\">" +
"<span style=\"font-size:12px;color:" + text + "\">Show Watch section</span>" +
"</label>" +
"<div style=\"margin-bottom:12px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Min score for Top 5 (0–160)</div>" +
"<input id=\"tsa-setting-top5-min\" type=\"number\" step=\"1\" min=\"0\" max=\"160\" value=\"" + getTop5MinScore() + "\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;\">" +
"</div>" +
"<label style=\"display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:10px\">" +
"<input type=\"checkbox\" id=\"tsa-setting-req-investors\"" + (getRequirePositiveInvestors() ? " checked" : "") + " style=\"width:15px;height:15px;cursor:pointer;accent-color:#4a6fa5\">" +
"<span style=\"font-size:12px;color:" + text + "\">Kræv positiv investor-delta i Top 5 Buy</span>" +
"</label>" +
"</div>" +
"<div style=\"border-top:1px solid " + border + ";margin-bottom:12px;padding-top:12px\">" +
"<div style=\"font-size:10px;letter-spacing:0.1em;color:" + muted + ";text-transform:uppercase;font-weight:bold;margin-bottom:10px\">Trading profit</div>" +
"<label style=\"display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:10px\">" +
"<input type=\"checkbox\" id=\"tsa-setting-swing-only\"" + (getProfitSwingOnly() ? " checked" : "") + " style=\"width:15px;height:15px;cursor:pointer\">" +
"<span style=\"font-size:12px;color:" + text + "\">Swing trade profit only</span>" +
"</label>" +
"<label style=\"display:flex;align-items:center;gap:8px;cursor:pointer;margin-bottom:10px\">" +
"<input type=\"checkbox\" id=\"tsa-setting-show-realized\"" + (getShowRealized() ? " checked" : "") + " style=\"width:15px;height:15px;cursor:pointer\">" +
"<span style=\"font-size:12px;color:" + text + "\">Show realized profit</span>" +
"</label>" +
"<div id=\"tsa-realized-options\" style=\"display:" + (getShowRealized() ? "block" : "none") + ";padding-left:23px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Period (days, 1–90)</div>" +
"<input id=\"tsa-setting-realized-days\" type=\"number\" step=\"1\" min=\"1\" max=\"90\" value=\"" + getRealizedDays() + "\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;margin-bottom:8px\">" +
"<button id=\"tsa-realized-reset\" style=\"width:100%;padding:7px;border-radius:7px;border:1px solid " + border + ";background:none;color:" + muted + ";font-size:12px;cursor:pointer\">Reset realized profit</button>" +
"</div>" +
"</div>" +
"<div style=\"margin-bottom:12px\">" +
"<div style=\"font-size:11px;color:" + muted + ";margin-bottom:4px\">Overlay position</div>" +
"<select id=\"tsa-setting-position\" style=\"width:100%;padding:7px 10px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:13px;\">" +
"<option value=\"bottom-right\"" + (getOverlayPosition() === "bottom-right" ? " selected" : "") + ">Bottom right</option>" +
"<option value=\"bottom-left\"" + (getOverlayPosition() === "bottom-left" ? " selected" : "") + ">Bottom left</option>" +
"<option value=\"top-right\"" + (getOverlayPosition() === "top-right" ? " selected" : "") + ">Top right</option>" +
"<option value=\"top-left\"" + (getOverlayPosition() === "top-left" ? " selected" : "") + ">Top left</option>" +
"</select>" +
"</div>" +
"<div style=\"display:flex;gap:8px\">" +
"<button id=\"tsa-settings-save\" style=\"flex:1;padding:8px;border-radius:7px;border:none;background:#4a6fa5;color:#fff;font-size:13px;font-weight:bold;cursor:pointer\">Save</button>" +
"<button id=\"tsa-settings-cancel\" style=\"flex:1;padding:8px;border-radius:7px;border:1px solid " + border + ";background:none;color:" + muted + ";font-size:13px;cursor:pointer\">Cancel</button>" +
"</div>" +
"</div>";
// Toggle realized options visibility
document.getElementById("tsa-setting-show-realized").addEventListener("change", function() {
document.getElementById("tsa-realized-options").style.display = this.checked ? "block" : "none";
});
document.getElementById("tsa-realized-reset").addEventListener("click", function() {
lsSet("tsa_realized_events", "[]");
lsSet("tsa_prev_holdings", "{}");
showToast("Realized profit reset");
loadData();
});
document.getElementById("tsa-settings-save").addEventListener("click", function() {
var profit = parseFloat(document.getElementById("tsa-setting-profit").value);
var stop = parseFloat(document.getElementById("tsa-setting-stoploss").value);
var ar = parseInt(document.getElementById("tsa-setting-autorefresh").value, 10);
var hd = parseInt(document.getElementById("tsa-setting-histdays").value, 10);
var swingOnly = document.getElementById("tsa-setting-swing-only").checked;
var showRealized = document.getElementById("tsa-setting-show-realized").checked;
var showWatch = document.getElementById("tsa-setting-show-watch").checked;
var top5Min = parseInt(document.getElementById("tsa-setting-top5-min").value, 10);
var reqInv = document.getElementById("tsa-setting-req-investors").checked;
var rd = parseInt((document.getElementById("tsa-setting-realized-days") || {}).value || "7", 10);
var posVal = document.getElementById("tsa-setting-position").value;
if (isNaN(profit) || profit <= 0) { showToast("Invalid profit target", "warn"); return; }
if (isNaN(stop) || stop <= 0) { showToast("Invalid stop loss", "warn"); return; }
if (isNaN(ar) || ar < 0) { showToast("Invalid auto-refresh interval", "warn"); return; }
if (isNaN(hd) || hd < 1 || hd > 30) { showToast("History must be 1–30 days", "warn"); return; }
if (isNaN(top5Min) || top5Min < 0 || top5Min > 160) { showToast("Min score must be 0–160", "warn"); return; }
if (showRealized && (isNaN(rd) || rd < 1 || rd > 90)) { showToast("Period must be 1–90 days", "warn"); return; }
lsSet("tsa_profit_target", profit.toString());
lsSet("tsa_stop_loss", stop.toString());
lsSet("tsa-auto-refresh-interval", ar.toString());
lsSet("tsa_history_days", hd.toString());
lsSet("tsa_profit_swing_only", swingOnly ? "true" : "false");
lsSet("tsa_show_watch", showWatch ? "true" : "false");
lsSet("tsa_top5_min_score", top5Min.toString());
lsSet("tsa_show_realized", showRealized ? "true" : "false");
lsSet("tsa_require_positive_investors", reqInv ? "true" : "false");
if (showRealized && !isNaN(rd)) lsSet("tsa_realized_days", rd.toString());
lsSet("tsa_overlay_position", posVal);
applyOverlayPosition(posVal);
var saveBtn = document.getElementById("tsa-settings-save");
if (saveBtn) {
saveBtn.textContent = "Saved ✓";
saveBtn.style.background = "#1a8a45";
setTimeout(function() { loadData(); }, 700);
} else {
loadData();
}
});
document.getElementById("tsa-settings-cancel").addEventListener("click", function() {
loadData();
});
});
document.getElementById("tsa-update-btn").addEventListener("click", function() {
loadData();
});
document.getElementById("tsa-alerts-btn").addEventListener("click", function() {
requestNotificationPermission();
var content = document.getElementById("tsa-content");
var isDarkNow = overlay.classList.contains("tsa-dark");
var bg2 = isDarkNow ? "#1a1a2e" : "#f7f9fc";
var border = isDarkNow ? "#2a2a4a" : "#eee";
var text = isDarkNow ? "#c8c8d8" : "#222";
var muted = isDarkNow ? "#7a7a9a" : "#666";
var alerts = loadAlerts();
var rows = alerts.length ? alerts.map(function(a) {
var repeatBadge = a.repeat
? "<span title=\"Repeating alert\" style=\"font-size:10px;margin-left:4px;opacity:0.7\">🔁</span>"
: "";
return "<div style=\"display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid " + border + ";font-size:12px;\">" +
"<span style=\"color:" + text + ";font-weight:bold\">" + a.sym + repeatBadge + "</span>" +
"<span style=\"color:" + muted + "\">" + (a.dir === "above" ? "≥" : "≤") + " $" + parseFloat(a.price).toFixed(2) + "</span>" +
"<button data-sym=\"" + a.sym + "\" data-dir=\"" + a.dir + "\" class=\"tsa-alert-del\" style=\"border:none;background:none;color:#cc2222;cursor:pointer;font-size:14px;\">✕</button>" +
"</div>";
}).join("") : "<div style=\"color:" + muted + ";font-size:11px;padding:8px 0\">No active alerts</div>";
var stockOpts = ["ASS","BAG","CBD","CNC","ELT","EVL","EWM","FHG","GRN","HRG","IIL","IOU","IST","LAG","LOS","LSC","MCS","MSG","MUN","PRN","PTS","SYM","SYS","TCC","TCI","TCM","TCP","TCT","TGP","THS","TMI","TSB","WLT","WSU","YAZ"]
.map(function(s) { return "<option value=\"" + s + "\">" + s + "</option>"; }).join("");
content.innerHTML =
"<div style=\"padding:14px\">" +
"<div style=\"font-size:10px;letter-spacing:0.12em;color:" + muted + ";text-transform:uppercase;font-weight:bold;margin-bottom:14px\">Price Alerts</div>" +
"<div style=\"margin-bottom:14px\">" + rows + "</div>" +
"<div style=\"display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:6px;margin-bottom:6px;align-items:center\">" +
"<select id=\"tsa-alert-sym\" style=\"padding:6px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:12px\">" + stockOpts + "</select>" +
"<input id=\"tsa-alert-price\" type=\"number\" step=\"0.01\" min=\"0\" placeholder=\"Price\" style=\"padding:6px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:12px\">" +
"<select id=\"tsa-alert-dir\" style=\"padding:6px;border-radius:7px;border:1px solid " + border + ";background:" + bg2 + ";color:" + text + ";font-size:12px\"><option value=\"above\">≥ Over</option><option value=\"below\">≤ Under</option></select>" +
"<button id=\"tsa-alert-add\" style=\"padding:6px 10px;border-radius:7px;border:none;background:#4a6fa5;color:#fff;font-size:13px;font-weight:bold;cursor:pointer\">+</button>" +
"</div>" +
"<label style=\"display:flex;align-items:center;gap:6px;font-size:11px;color:" + muted + ";margin-bottom:10px;cursor:pointer\">" +
"<input type=\"checkbox\" id=\"tsa-alert-repeat\" style=\"cursor:pointer\"> 🔁 Repeat — keep alert active after it fires" +
"</label>" +
"<button id=\"tsa-alerts-back\" style=\"width:100%;padding:8px;border-radius:7px;border:1px solid " + border + ";background:none;color:" + muted + ";font-size:13px;cursor:pointer\">Back</button>" +
"</div>";
document.getElementById("tsa-alert-add").addEventListener("click", function() {
var sym = document.getElementById("tsa-alert-sym").value;
var price = parseFloat(document.getElementById("tsa-alert-price").value);
var dir = document.getElementById("tsa-alert-dir").value;
var repeat = document.getElementById("tsa-alert-repeat").checked;
if (!sym || isNaN(price) || price <= 0) { showToast("Enter a stock and valid price", "warn"); return; }
addAlert(sym, price, dir, repeat);
document.getElementById("tsa-alerts-btn").click();
});
document.querySelectorAll(".tsa-alert-del").forEach(function(btn) {
btn.addEventListener("click", function() {
removeAlert(btn.dataset.sym, btn.dataset.dir);
document.getElementById("tsa-alerts-btn").click();
});
});
document.getElementById("tsa-alerts-back").addEventListener("click", function() { loadData(); });
});
document.getElementById("tsa-roi-btn").addEventListener("click", function() {
roiPlannerActive = !roiPlannerActive;
document.getElementById("tsa-roi-btn").style.opacity = roiPlannerActive ? "1" : "0.7";
if (roiPlannerActive && lastOwnedMap) {
showROIPlanner(lastOwnedMap, lastRaw);
} else if (!roiPlannerActive) {
loadData();
}
});
document.body.appendChild(overlay);
btn.addEventListener("click", function() {
var isOpen = overlay.style.display === "block";
if (isOpen) {
overlay.style.display = "none";
overlay.classList.remove("tsa-visible");
} else {
overlay.style.display = "block";
overlay.classList.remove("tsa-visible");
// Force reflow so animation triggers fresh
void overlay.offsetWidth;
overlay.classList.add("tsa-visible");
loadData();
}
});
document.getElementById("tsa-close").addEventListener("click", function() {
overlay.style.display = "none";
overlay.classList.remove("tsa-visible");
});
// Swipe-down to close on mobile — only triggers when the swipe starts in
// the top 60px of the overlay (the header area), so normal scrolling inside
// the list is never accidentally intercepted.
if (/Mobi|Android/i.test(navigator.userAgent)) {
var swipeTouchStartY = 0;
var swipeTouchStartOverlayY = 0;
overlay.addEventListener("touchstart", function(e) {
var overlayTop = overlay.getBoundingClientRect().top;
swipeTouchStartY = e.touches[0].clientY;
swipeTouchStartOverlayY = swipeTouchStartY - overlayTop;
}, { passive: true });
overlay.addEventListener("touchend", function(e) {
if (swipeTouchStartOverlayY > 60) return; // started below header — ignore
var deltaY = e.changedTouches[0].clientY - swipeTouchStartY;
if (deltaY >= 80) {
overlay.style.display = "none";
overlay.classList.remove("tsa-visible");
}
}, { passive: true });
}
var initBtn = document.getElementById("tsa-init-btn"); if (initBtn) initBtn.addEventListener("click", loadData);
}
var _uiCreated = false;
function createUIOnce() {
if (_uiCreated) return;
_uiCreated = true;
createUI();
// Silently prefetch owned stock data on load so benefit lock, swing shares
// label, and all sell guards work immediately without opening the TSA panel.
(function prefetchOwnedMap() {
var key = getTornKey();
if (!key || key === "###PDA-APIKEY###") return;
fetchJSON("https://api.torn.com/user/?selections=stocks&key=" + key)
.then(function(tornData) {
if (!tornData || tornData.error) return;
var map = buildOwnedMap(tornData);
enrichOwnedMap(map, null);
lastOwnedMap = map;
qtUpdateExec(); // refresh swing shares label in QT bar
})
.catch(function() {});
})();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", createUIOnce);
} else if (document.readyState === "interactive") {
window.addEventListener("load", createUIOnce);
} else {
createUIOnce();
}
})();