// ==UserScript== // @name Torn Stock Advisor // @namespace torn_stock_advisor // @version 1.4.1 // @description Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table. // @author TheOddSod (2640064) // @match https://www.torn.com/page.php* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @connect api.torn.com // @downloadURL none // ==/UserScript== // ─── Changelog ─────────────────────────────────────────────────────────────── // v1.4.1 — Polish: table alternating rows, tighter column layout, ROI to 3dp. // v1.4.0 — Feature: budget filter. Enter available cash in config; blocks // above (budget × threshold%) are hidden from recommendations, // greyed in table, or shown normally — user's choice. // Budget and threshold % both configurable. Budget shown in strip. // v1.3.2 — Cleanup: removed debug console.log statements. Script is stable. // v1.3.1 — Fix: migrateConfig IIFE moved to after STOCK_DATA declaration // to fix ReferenceError on initialization. // v1.3.0 — Fix: add config version migration — stale threshold overrides // from old versions are automatically cleared on script update, // so corrected defaults always take effect without manual reset. // v1.2.9 — Fix: all remaining passive stock thresholds corrected from in-game // screenshots. WLT=9M, SYS=3M, LOS=7.5M, TCC=7.5M, IST=100k, // MSG=300k. TCC confirmed as item (Clothing Cache) every 31 days. // v1.2.8 — Fix: all thresholds and payouts corrected from in-game detail pages. // GRN payout $8M (was $4M). TCT payout $3M (was $1M). // ASS pays 2x Six Pack per block. LSC pays 2x Lottery Vouchers // per block every 7 days (was 31 days). MCS B3 threshold corrected. // v1.2.7 — UI: renamed all user-facing "increment"/"incr" labels to "block". // v1.2.6 — Fix: TSB Block 1 threshold corrected to 3,000,000 (was 1,000,000). // CNC Block 1 threshold corrected to 7,500,000 (was 1,000,000). // SYM payout corrected: each increment = 1 Drug Pack (3x shown // in game means user holds 3 increments). Thresholds verified // from in-game stock detail pages. // v1.2.5 — Fix: price correctly read from market.price nested object in // torn/stocks API response. Recommendations de-duplicated per stock // to show only the best next increment (not all future ones). // v1.2.4 — Fix: log full torn/stocks first entry to see acronym value; // DOM reader improved to handle Torn table structure where price // is in a div inside the cell, not the raw text. // v1.2.3 — Fix: /torn/stocks has no acronym field — now matches by stock name // against STOCK_DATA to resolve ticker. DOM price reader fixed to // scan all text content for ticker pattern, not just cells[0]. // v1.2.2 — Fix: stock price parsing hardened — log first torn/stocks entry to // find correct field name; parse price as float; also read price // directly from the DOM table as reliable fallback. // v1.2.1 — Fix: stock price lookup now correctly falls back to torn/stocks // price endpoint for all tickers regardless of payout type. // Table redesigned: fewer columns, wider cells, cleaner headings. // Recommendations now show when item prices resolve correctly. // v1.2.0 — Fix: item price lookup switched from bazaar to itemmarket selection // (bazaar returns empty for supply pack items). Also tries // the torn/items endpoint for market_value as final fallback. // v1.1.9 — Fix: item IDs resolved at runtime via /v2/torn/items API call, // storing a name->id cache in GM storage. Eliminates hardcoded // item IDs that were wrong. FHC=367 and Drug Pack=370 confirmed; // all others now auto-resolved by name match. // v1.1.8 — Fix: item market prices now use v1 API (confirmed working). // FHG Feathery Hotel Coupon item ID corrected to 367. // Item IDs for other stocks flagged for verification. // v1.1.7 — Fix: item market price endpoint corrected to v2 path format. // Holdings section now groups by ticker (not per-increment row). // Recommendations now show correctly when item prices load. // v1.1.6 — Fix: SyntaxError from malformed comment (newline in string literal) // that prevented the script from loading entirely. // v1.1.5 — Fix: API response fields corrected from live inspection. // /user/stocks returns {id, shares, bonus{...}} — no acronym. // /torn/stocks used to map id->acronym and get current prices. // DOM table fallback retained for share counts. // v1.1.4 — Fix: inject into #stockmarketroot (confirmed via DOM inspection). // Holdings now read from dividendStatus{TICKER} DOM elements as // fallback if API returns empty — these are always present on page. // API parsing also logs full first-entry shape for debugging. // v1.1.3 — Fix: injection now targets #mainContainer directly (right column); // previous approach walked into the sidebar instead. API response // parsing hardened with console debug output. // v1.1.2 — Fix: injection completely rewritten — finds the stocks content div // by walking up from the Stocks Filter element; falls back to // appending to #mainContainer. Removed overly broad @match rule. // v1.1.1 — Fix: API calls corrected to v2 /user/stocks and /torn/stocks endpoints // (v1 selections= format was returning empty data). Injection point // moved to sit directly above the stock table. // v1.1.0 — Major overhaul: // - Fixed payout logic: each increment pays its own per-increment // value independently. Scoring uses incremental cost (shares for // that block only × price) vs incremental daily payout value. // - Added confirmed payout intervals (7-day vs 31-day) sourced from // Torn wiki and community guides. All payouts normalised to daily // value for fair cross-stock comparison. // - UI redesigned: top N recommendation cards (default 5), collapsible // holdings section showing active and partial increments, full // rankings table collapsed by default. // - Passive stocks (no active payout) separated; excluded by default // but shown in table and configurable. // - All ranked rows show incremental cost/ROI, not cumulative totals. // v1.0.3 — Fix: SYM Drug Pack item ID corrected to 370. // v1.0.2 — Fix: complete STOCK_DATA overhaul — all stocks named correctly. // v1.0.1 — Fix: replaced GM_xmlHttpRequest with native fetch(). // v1.0.0 — Initial release. // ───────────────────────────────────────────────────────────────────────────── (function () { 'use strict'; // ─── Duplicate injection guard ─────────────────────────────────────────────── if (window._tsaLoaded) return; window._tsaLoaded = true; // ─── Constants ─────────────────────────────────────────────────────────────── const PREFIX = 'tsa_'; const API_BASE = 'https://api.torn.com/v2'; const SCRIPT = 'TornStockAdvisor'; // ─── Config version — migration runs after STOCK_DATA is defined ─────────── const CONFIG_VERSION = '1.3.1'; // ─── Master stock data ──────────────────────────────────────────────────────── // // PAYOUT LOGIC (Torn Stocks 3.0): // Each "increment" pays its own per-increment value independently. // Block 2 gives you a SECOND identical payout each cycle — not double total. // Scoring: incremental cost (shares for THIS block only × price) // vs incremental daily value (payout / interval in days). // // INTERVALS (sourced: Torn wiki + community guides, verified): // 7 days: FHG, SYM, PRN, EWM, THS, LAG, BAG, MUN, PTS, EVL, MCS, CBD // 31 days: GRN, TCT, TMI, IOU, ASS, TSB, CNC, HRG, LSC, TCC // 0 (passive): TCP, TCM, TGP, IIL, TCI, WLT, SYS, ELT, MSG, WSU, LOS, YAZ, IST // // FIELDS: // payoutInterval — days between payouts (7 or 31; 0 = passive, not scored) // perIncrQty — units paid per increment per interval // payoutItemId — Torn item ID for live market lookup (null = not an item) // payoutCashValue— fixed $ per increment per interval (0 if item/passive) // increments — [{incr, threshold}] where threshold = TOTAL shares held // (each block's incremental cost = threshold - prev threshold) const STOCK_DATA = [ // ── 7-day active dividend stocks ───────────────────────────────────────── { ticker: 'FHG', name: 'Feathery Hotels Group', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Feathery Hotel Coupon', payoutItemId: 367, payoutCashValue: 0, payoutDesc: '1× Feathery Hotel Coupon per block, every 7 days', increments: [ { incr: 1, threshold: 2000000 }, { incr: 2, threshold: 6000000 }, { incr: 3, threshold: 14000000 }, { incr: 4, threshold: 30000000 }, { incr: 5, threshold: 62000000 }, ], }, { ticker: 'SYM', name: 'Symbiotic Ltd.', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Drug Pack', payoutItemId: 370, payoutCashValue: 0, payoutDesc: '1× Drug Pack per block, every 7 days', increments: [ { incr: 1, threshold: 500000 }, { incr: 2, threshold: 1500000 }, { incr: 3, threshold: 3500000 }, { incr: 4, threshold: 7500000 }, { incr: 5, threshold: 15500000 }, ], }, { ticker: 'PRN', name: 'Performance Ribaldry', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Erotic DVD', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Erotic DVD per block, every 7 days', increments: [ { incr: 1, threshold: 1000000 }, { incr: 2, threshold: 3000000 }, { incr: 3, threshold: 7000000 }, { incr: 4, threshold: 15000000 }, { incr: 5, threshold: 31000000 }, ], }, { ticker: 'EWM', name: 'Eaglewood Mercenary', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Box of Grenades', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Box of Grenades per block, every 7 days', increments: [ { incr: 1, threshold: 1000000 }, { incr: 2, threshold: 3000000 }, { incr: 3, threshold: 7000000 }, { incr: 4, threshold: 15000000 }, { incr: 5, threshold: 31000000 }, ], }, { ticker: 'THS', name: 'Torn City Health Service', payoutType: 'item', payoutInterval: 7, perIncrQty: 2, payoutItemName: 'Box of Medical Supplies', payoutItemId: null, payoutCashValue: 0, payoutDesc: '2× Boxes of Medical Supplies per block, every 7 days', increments: [ { incr: 1, threshold: 150000 }, { incr: 2, threshold: 450000 }, { incr: 3, threshold: 1050000 }, { incr: 4, threshold: 2250000 }, { incr: 5, threshold: 4650000 }, ], }, { ticker: 'LAG', name: 'Legal Authorities Group', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Lawyer Business Card', payoutItemId: null, payoutCashValue: 0, payoutDesc: "1× Lawyer's Business Card per block, every 7 days", increments: [ { incr: 1, threshold: 750000 }, { incr: 2, threshold: 2250000 }, { incr: 3, threshold: 5250000 }, { incr: 4, threshold: 11250000 }, { incr: 5, threshold: 23250000 }, ], }, { ticker: 'BAG', name: "Big Al's Gun Shop", payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Ammunition Pack', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Ammunition Pack per block, every 7 days', increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'MUN', name: 'Munster Beverage Corp.', payoutType: 'item', payoutInterval: 7, perIncrQty: 1, payoutItemName: 'Six-Pack of Energy Drink', payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Six-Pack of Energy Drink per block, every 7 days', increments: [ { incr: 1, threshold: 5000000 }, { incr: 2, threshold: 15000000 }, { incr: 3, threshold: 35000000 }, { incr: 4, threshold: 75000000 }, { incr: 5, threshold: 155000000 }, ], }, { ticker: 'PTS', name: 'PointLess', // Points value varies; user should set manual value in config payoutType: 'other', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '100 points per block, every 7 days — set $ value in config', increments: [ { incr: 1, threshold: 10000000 }, { incr: 2, threshold: 30000000 }, { incr: 3, threshold: 70000000 }, { incr: 4, threshold: 150000000 }, { incr: 5, threshold: 310000000 }, ], }, { ticker: 'EVL', name: 'Evil Ducks Candy Corp', payoutType: 'happy', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '1000 happiness per block, every 7 days', increments: [ { incr: 1, threshold: 100000 }, { incr: 2, threshold: 300000 }, { incr: 3, threshold: 700000 }, { incr: 4, threshold: 1500000 }, { incr: 5, threshold: 3100000 }, ], }, { ticker: 'MCS', name: 'Mc Smoogle Corp', payoutType: 'energy', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '200 energy per block, every 7 days', // Confirmed: B2=1,050,000 (held), B3 needs +1,400,000 = 2,450,000 total // Payout is 200 energy (not 100) per block per wiki screenshot increments: [ { incr: 1, threshold: 350000 }, { incr: 2, threshold: 1050000 }, { incr: 3, threshold: 2450000 }, { incr: 4, threshold: 5250000 }, { incr: 5, threshold: 10850000 }, ], }, { ticker: 'CBD', name: 'Herbal Releaf Co.', payoutType: 'nerve', payoutInterval: 7, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '50 nerve per block, every 7 days', increments: [ { incr: 1, threshold: 350000 }, { incr: 2, threshold: 1050000 }, { incr: 3, threshold: 2450000 }, { incr: 4, threshold: 5250000 }, { incr: 5, threshold: 10850000 }, ], }, // ── 31-day active dividend stocks ──────────────────────────────────────── { ticker: 'GRN', name: 'Grain', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 8000000, payoutDesc: '$8M per block, every 31 days', increments: [ { incr: 1, threshold: 500000 }, { incr: 2, threshold: 1500000 }, { incr: 3, threshold: 3500000 }, { incr: 4, threshold: 7500000 }, { incr: 5, threshold: 15500000 }, ], }, { ticker: 'TCT', name: 'The Torn City Times', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 3000000, payoutDesc: '$3M per block, every 31 days', // Confirmed: B1=100k, B3=700k (you hold), B4 needs 800k more = 1,500k total increments: [ { incr: 1, threshold: 100000 }, { incr: 2, threshold: 300000 }, { incr: 3, threshold: 700000 }, { incr: 4, threshold: 1500000 }, { incr: 5, threshold: 3100000 }, ], }, { ticker: 'TMI', name: 'TC Music Industries', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 25000000, payoutDesc: '$25M per block, every 31 days', increments: [ { incr: 1, threshold: 6000000 }, { incr: 2, threshold: 18000000 }, { incr: 3, threshold: 42000000 }, { incr: 4, threshold: 90000000 }, { incr: 5, threshold: 186000000 }, ], }, { ticker: 'IOU', name: 'Insured On Us', // Base $12M + class-action lawsuit chance — value listed is base only payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 12000000, payoutDesc: '$12M per block, every 31 days (+ lawsuit payout chance)', increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'ASS', name: 'Alcoholics Synonymous', payoutType: 'item', payoutInterval: 31, perIncrQty: 2, payoutItemName: 'Six-Pack of Alcohol', payoutItemId: null, payoutCashValue: 0, payoutDesc: '2× Six Pack of Alcohol per block, every 31 days', // Confirmed: B1=3,000,000 shares, 2x Six Pack per block per cycle increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'TSB', name: 'Torn & Shanghai Banking', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 50000000, payoutDesc: '$50M per block, every 31 days', // Confirmed from in-game: Block 1 = 3,000,000 shares increments: [ { incr: 1, threshold: 3000000 }, { incr: 2, threshold: 9000000 }, { incr: 3, threshold: 21000000 }, { incr: 4, threshold: 45000000 }, { incr: 5, threshold: 93000000 }, ], }, { ticker: 'CNC', name: 'Crude & Co', payoutType: 'cash', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 80000000, payoutDesc: '$80M per block, every 31 days', // Confirmed from in-game: Block 1 = 7,500,000 shares increments: [ { incr: 1, threshold: 7500000 }, { incr: 2, threshold: 22500000 }, { incr: 3, threshold: 52500000 }, { incr: 4, threshold: 112500000 }, { incr: 5, threshold: 232500000 }, ], }, { ticker: 'LSC', name: 'Lucky Shot Casino', // Confirmed: 2x Lottery Vouchers per block, every 7 days. B1=1,500,000 shares. payoutType: 'item', payoutInterval: 7, perIncrQty: 2, payoutItemName: 'Lottery Voucher', payoutItemId: null, payoutCashValue: 0, payoutDesc: '2× Lottery Vouchers per block, every 7 days', increments: [ { incr: 1, threshold: 1500000 }, { incr: 2, threshold: 4500000 }, { incr: 3, threshold: 10500000 }, { incr: 4, threshold: 22500000 }, { incr: 5, threshold: 46500000 }, ], }, { ticker: 'HRG', name: 'Home Retail Group', // Random Property every 31 days (same payout type as LSC) payoutType: 'other', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Random Property per block, every 31 days — set $ value in config', increments: [ { incr: 1, threshold: 10000000 }, { incr: 2, threshold: 30000000 }, { incr: 3, threshold: 70000000 }, { incr: 4, threshold: 150000000 }, { incr: 5, threshold: 310000000 }, ], }, { ticker: 'TCC', name: 'Torn City Clothing', // Confirmed: B1=7,500,000 shares. 1x Clothing Cache every 31 days. // Clothing Cache item ID unknown — set value manually in config. payoutType: 'item', payoutInterval: 31, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '1× Clothing Cache per block, every 31 days — set value in config', increments: [ { incr: 1, threshold: 7500000 }, { incr: 2, threshold: 22500000 }, { incr: 3, threshold: 52500000 }, { incr: 4, threshold: 112500000 }, { incr: 5, threshold: 232500000 }, ], }, // ── Passive stocks (no active payout — excluded from scoring by default) ─ { ticker: 'TCP', name: 'TC Media Productions', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Company sales boost (passive — set value in config to score)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'TCM', name: 'Torn City Motors', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% racing skill gain boost (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'TGP', name: 'Tell Group Plc.', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Company advertising boost (passive)', increments: [ { incr: 1, threshold: 2500000 } ], }, { ticker: 'IIL', name: 'I Industries Ltd.', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '50% coding time reduction (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'TCI', name: 'Torn City Investments', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% bank interest bonus (passive — hold for 7 days before banking)', increments: [ { incr: 1, threshold: 1500000 } ], }, { ticker: 'WLT', name: 'Wind Lines Travel', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Private jet access (passive)', increments: [ { incr: 1, threshold: 9000000 } ], }, { ticker: 'SYS', name: 'Syscore MFG', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Advanced firewall (passive)', increments: [ { incr: 1, threshold: 3000000 } ], }, { ticker: 'ELT', name: 'Empty Lunchbox Traders', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% home upgrade discount (passive)', increments: [ { incr: 1, threshold: 5000000 } ], }, { ticker: 'MSG', name: 'Messaging Inc.', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Free classified advertising (passive)', increments: [ { incr: 1, threshold: 300000 } ], }, { ticker: 'WSU', name: 'West Side University', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '10% course time reduction (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'LOS', name: 'Lo Squalo Waste', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: '25% mission reward bonus (passive)', increments: [ { incr: 1, threshold: 7500000 } ], }, { ticker: 'YAZ', name: 'Yazoo', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Free banner advertising (passive)', increments: [ { incr: 1, threshold: 1000000 } ], }, { ticker: 'IST', name: 'International School TC', payoutType: 'passive', payoutInterval: 0, perIncrQty: 1, payoutItemId: null, payoutCashValue: 0, payoutDesc: 'Free education courses (passive)', increments: [ { incr: 1, threshold: 100000 } ], }, ]; // ─── Config migration (runs after STOCK_DATA is defined) ─────────────────── (function migrateConfig() { const storedVersion = GM_getValue(PREFIX + 'cfg_version', ''); if (storedVersion === CONFIG_VERSION) return; console.log(`[TSA] Config version ${storedVersion} → ${CONFIG_VERSION}: clearing stale threshold/payout overrides`); for (const stock of STOCK_DATA) { GM_setValue(PREFIX + `payout_${stock.ticker}`, null); for (const { incr } of stock.increments) { GM_setValue(PREFIX + `thresh_${stock.ticker}_${incr}`, null); } } GM_setValue(PREFIX + 'cfg_version', CONFIG_VERSION); })(); // ─── CSS ────────────────────────────────────────────────────────────────────── GM_addStyle(` #tsa-root * { box-sizing: border-box; margin: 0; padding: 0; } #tsa-root { font-family: Arial, sans-serif; font-size: 13px; color: #e0e0e0; background: #16213e; border-radius: 6px; margin: 12px 0; overflow: hidden; } #tsa-header { background: #1a1a2e; border-bottom: 2px solid #e05a00; border-radius: 6px 6px 0 0; padding: 8px 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .tsa-title { color: #ff7700; font-size: 15px; font-weight: bold; } .tsa-version { font-size: 10px; opacity: 0.5; font-weight: normal; } .tsa-updated { color: #888; font-size: 11px; margin-left: auto; } .tsa-btn-primary { background: #e05a00; border: none; border-radius: 4px; color: #fff; padding: 4px 10px; cursor: pointer; font-size: 12px; } .tsa-btn-primary:hover { background: #ff7700; } .tsa-btn-secondary { background: #1a2a4a; border: 1px solid #2a4a7a; border-radius: 4px; color: #aaa; padding: 3px 8px; cursor: pointer; font-size: 11px; } .tsa-btn-secondary:hover { background: #2a3a5a; color: #fff; } #tsa-config-strip { background: #16213e; border-bottom: 1px solid #1a2a4a; padding: 5px 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: #888; } .tsa-sep { color: #2a2a4a; } .tsa-key-ok { color: #44ee88; font-size: 10px; font-weight: bold; } .tsa-key-bad { color: #ff4444; font-size: 10px; font-weight: bold; } #tsa-config-panel { background: #111827; border-bottom: 2px solid #e05a00; padding: 10px 12px; display: none; } #tsa-config-panel.open { display: block; } .tsa-cfg-label { font-size: 9px; color: #666; text-transform: uppercase; letter-spacing: .5px; margin: 10px 0 5px; display: block; } .tsa-cfg-label:first-child { margin-top: 0; } .tsa-cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } .tsa-cfg-row label { font-size: 11px; color: #aaa; min-width: 160px; } .tsa-cfg-input { background: #0f3460; border: 1px solid #2a4a7a; border-radius: 4px; color: #fff; padding: 4px 8px; font-size: 12px; } .tsa-cfg-input:focus { outline: none; border-color: #ff7700; } .tsa-cfg-input option { background: #0f1a30; color: #e0e0e0; } .tsa-row-overbudget td { opacity: 0.4; } .tsa-cfg-note { font-size: 10px; color: #555; margin-top: 3px; } .tsa-cfg-check-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .tsa-cfg-check-row label { font-size: 11px; color: #aaa; } .tsa-accord { border: 1px solid #1a2a4a; border-radius: 4px; margin-bottom: 4px; } .tsa-accord-hdr { background: #0f1a30; padding: 5px 10px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: #aaa; border-radius: 4px; user-select: none; } .tsa-accord-hdr:hover { background: #1a2a4a; color: #fff; } .tsa-accord-ticker { background: #1a2a4a; color: #7aadff; font-size: 10px; font-weight: bold; padding: 1px 6px; border-radius: 3px; font-family: monospace; margin-right: 8px; } .tsa-accord-arrow { font-size: 10px; transition: transform .2s; } .tsa-accord-hdr.open .tsa-accord-arrow { transform: rotate(180deg); } .tsa-accord-body { display: none; padding: 8px 10px; background: #0a1020; border-top: 1px solid #1a2a4a; } .tsa-accord-body.open { display: block; } .tsa-incr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; font-size: 11px; flex-wrap: wrap; } .tsa-incr-row label { color: #666; min-width: 130px; } .tsa-incr-row .tsa-cfg-input { width: 130px; } .tsa-incr-note { font-size: 10px; color: #444; } #tsa-stats-bar { background: #0f1a30; border-bottom: 1px solid #1a2a4a; padding: 6px 12px; display: flex; gap: 24px; flex-wrap: wrap; align-items: center; } .tsa-stat { display: flex; flex-direction: column; } .tsa-stat-label { font-size: 10px; color: #aaa; text-transform: uppercase; letter-spacing: .5px; } .tsa-stat-value { font-size: 16px; font-weight: bold; color: #ff7700; } #tsa-body { padding: 10px; } #tsa-error { background: #2a0000; border: 1px solid #882200; border-radius: 5px; padding: 8px 12px; margin-bottom: 10px; font-size: 12px; color: #ff8866; display: none; } #tsa-loading { text-align: center; padding: 20px; color: #555; font-size: 12px; display: none; } .tsa-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #444; border-top-color: #ff7700; border-radius: 50%; animation: tsa-spin .7s linear infinite; vertical-align: middle; margin-right: 6px; } @keyframes tsa-spin { to { transform: rotate(360deg); } } .tsa-section { margin-bottom: 12px; } .tsa-section-title { color: #ff7700; font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #333; padding-bottom: 3px; margin-bottom: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none; } .tsa-section-title::after { content: '▾'; font-size: 10px; transition: transform .2s; } .tsa-section-title.collapsed::after { transform: rotate(-90deg); } /* Recommendation cards */ #tsa-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(270px,100%),1fr)); gap: 8px; margin-bottom: 4px; } .tsa-card { background: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px; padding: 8px 10px; position: relative; } .tsa-card-rank { position: absolute; top: 8px; right: 10px; font-size: 12px; font-weight: bold; color: #333; } .tsa-rank-gold { color: #ffcc44; } .tsa-rank-silver { color: #aaa; } .tsa-rank-bronze { color: #cc7722; } .tsa-card-head { font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 3px; padding-right: 24px; } .tsa-card-sub { margin-bottom: 5px; } .tsa-card-line { font-size: 11px; color: #888; margin-bottom: 3px; } .tsa-card-line strong { color: #e0e0e0; } .tsa-card-roi { font-size: 10px; color: #ff7700; font-weight: bold; margin-top: 4px; } .tsa-card-partial { border-top: 1px solid #2a2a4a; margin-top: 5px; padding-top: 4px; font-size: 10px; color: #ffaa00; } /* Holdings */ .tsa-hold-sublabel { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; } .tsa-hold-sublabel + .tsa-hold-sublabel, .tsa-hold-row + .tsa-hold-sublabel { margin-top: 8px; } .tsa-hold-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid #111; flex-wrap: wrap; font-size: 11px; } .tsa-hold-row:last-child { border-bottom: none; } .tsa-hold-name { color: #ccc; flex: 1; min-width: 140px; } .tsa-hold-val { color: #44ee88; } .tsa-hold-partial-txt { color: #ffaa00; font-size: 10px; } /* Table */ table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; } table.tsa-table th { color: #666; font-weight: normal; font-size: 10px; text-transform: uppercase; letter-spacing: .5px; padding: 5px 10px; border-bottom: 2px solid #1a2a4a; text-align: left; white-space: nowrap; background: #0f1a30; } table.tsa-table th.r, table.tsa-table td.r { text-align: right; } table.tsa-table td { padding: 5px 10px; border-bottom: 1px solid #0d1525; color: #ccc; white-space: nowrap; vertical-align: middle; } table.tsa-table tbody tr:nth-child(even) td { background: #111827; } table.tsa-table tbody tr:nth-child(odd) td { background: #0f1520; } table.tsa-table tr:hover td { background: #1e1e36 !important; } table.tsa-table tr.tsa-row-ignored td { opacity: 0.25; } table.tsa-table tr.tsa-row-passive td { opacity: 0.40; } /* Sticky rank column */ table.tsa-table th:first-child, table.tsa-table td:first-child { text-align: center; padding: 5px 6px; border-right: 1px solid #1a2a4a; } /* Badges */ .tsa-badge { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: bold; white-space: nowrap; display: inline-block; } .tsa-badge-ok { background: #004422; color: #44ee88; } .tsa-badge-warn { background: #2a1a00; color: #ff9900; } .tsa-badge-info { background: #0f3460; color: #7aadff; } .tsa-badge-muted { background: #111; color: #444; } .tsa-badge-energy { background: #1a2a00; color: #aaee44; } .tsa-badge-nerve { background: #330033; color: #dd44dd; } .tsa-badge-happy { background: #2a1a00; color: #ffcc44; } .tsa-badge-passive { background: #1a1a2e; color: #555; } .tsa-badge-cash { background: #004422; color: #44ee88; } .tsa-badge-item { background: #0f3460; color: #7aadff; } .tsa-badge-other { background: #111; color: #888; } .tsa-ticker { font-family: monospace; font-weight: bold; color: #fff; background: #0f1a30; padding: 1px 5px; border-radius: 3px; font-size: 11px; } .tsa-rank-num { font-size: 11px; font-weight: bold; color: #555; display: inline-block; width: 18px; text-align: right; } #tsa-footer { border-top: 1px solid #1a2a4a; padding: 6px 12px; font-size: 10px; color: #444; display: flex; justify-content: space-between; } /* Dashboard collapse */ #tsa-root.collapsed > *:not(#tsa-header) { display: none !important; } #tsa-root.collapsed { border-radius: 6px; } #tsa-root.collapsed #tsa-header { border-radius: 6px; border-bottom: none; } `); // ─── Helpers ───────────────────────────────────────────────────────────────── function fmtMoney(v) { if (v === null || v === undefined || isNaN(v)) return '—'; if (v >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'B'; if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M'; if (v >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'k'; return '$' + Math.round(v).toLocaleString(); } function fmtShares(n) { if (!n) return '0'; 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(1) + 'k'; return n.toLocaleString(); } function fmtROI(dailyVal, cost) { if (!cost || !dailyVal || cost <= 0) return '—'; return ((dailyVal / cost) * 100).toFixed(3) + '%/day'; } function save(k, v) { GM_setValue(PREFIX + k, v); } function load(k, d) { const v = GM_getValue(PREFIX + k, d); return (v !== undefined && v !== null) ? v : d; } function wireCollapse(titleEl, contentEl, storeKey, def = 'open') { const saved = load(storeKey, def); if (saved === 'collapsed') { contentEl.style.display = 'none'; titleEl.classList.add('collapsed'); } titleEl.addEventListener('click', () => { const hidden = contentEl.style.display === 'none'; contentEl.style.display = hidden ? '' : 'none'; titleEl.classList.toggle('collapsed', !hidden); save(storeKey, hidden ? 'open' : 'collapsed'); }); } // ─── Config accessors ───────────────────────────────────────────────────────── const getApiKey = () => load('api_key', ''); const getIgnored = () => load('ignored', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean); const getExNerve = () => load('ex_nerve', true); const getExEnergy = () => load('ex_energy', true); const getExHappy = () => load('ex_happy', false); const getExOther = () => load('ex_other', false); const getExPassive = () => load('ex_passive', true); const getRefreshMins = () => parseInt(load('refresh_mins', 5), 10); const getBudget = () => parseFloat(load('budget', '0')) || 0; const getBudgetPct = () => parseFloat(load('budget_pct', '110')) || 110; // budget_mode: 'hide' | 'grey' | 'show' const getBudgetMode = () => load('budget_mode', 'grey'); const getTopN = () => parseInt(load('top_n', 5), 10); function getPayoutOverride(ticker, def) { const v = parseFloat(load(`payout_${ticker}`, '')); return (!isNaN(v) && v > 0) ? v : def; } function getThreshOverride(ticker, incr, def) { const v = parseInt(load(`thresh_${ticker}_${incr}`, ''), 10); return (!isNaN(v) && v > 0) ? v : def; } // ─── API ────────────────────────────────────────────────────────────────────── async function apiFetch(path, apiKey) { const sep = path.includes('?') ? '&' : '?'; const resp = await fetch(`${API_BASE}${path}${sep}key=${apiKey}&comment=${SCRIPT}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); if (data.error) throw new Error(`API ${data.error.code}: ${data.error.error}`); return data; } async function fetchItemPrice(apiKey, itemId) { // Try itemmarket selection first (works for supply pack items). // Fall back to bazaar, then to item market_value from torn/items cache. try { const resp = await fetch( `https://api.torn.com/v1/market/${itemId}?selections=itemmarket&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (!data.error) { const listings = data.itemmarket || []; if (listings.length) { return listings[0].cost || listings[0].price || 0; } } } catch { /* try next */ } // Try bazaar as fallback try { const resp = await fetch( `https://api.torn.com/v1/market/${itemId}?selections=bazaar&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (!data.error) { const listings = data.bazaar || []; if (listings.length) return listings[0].cost || listings[0].price || 0; } } catch { /* try next */ } // Final fallback: use market_value from the torn/items cache we already loaded try { const cached = load('item_id_cache', '{}'); // item_id_cache stores name->id, not id->market_value, so this won't work directly. // Instead, try the torn/items endpoint for this specific item's market_value. const resp = await fetch( `https://api.torn.com/v1/torn/${itemId}?selections=items&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (!data.error) { const item = (data.items || {})[itemId]; if (item && item.market_value) return item.market_value; } } catch { /* give up */ } return 0; } /** * Resolve item names to Torn item IDs using the v2 torn/items endpoint. * Results are cached in GM storage for 24 hours to avoid repeated API calls. * Returns a map of { itemName (lowercase) -> itemId }. */ async function resolveItemIds(apiKey, itemNames) { const CACHE_KEY = 'item_id_cache'; const CACHE_TIME = 'item_id_cache_time'; const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours // Try cache first const cachedTime = load(CACHE_TIME, 0); const cachedData = load(CACHE_KEY, '{}'); let nameToId = {}; try { nameToId = JSON.parse(cachedData); } catch { nameToId = {}; } // Check if all needed items are in cache and cache is fresh const needsRefresh = (Date.now() - cachedTime) > TTL_MS; const allCached = itemNames.every(n => nameToId[n.toLowerCase()] !== undefined); if (!needsRefresh && allCached) { return nameToId; } // Fetch from Torn API — v2 torn/items returns all items // We use the v1 items endpoint which is simpler and returns {items:{id:{name,...}}} try { const resp = await fetch( `https://api.torn.com/v1/torn/?selections=items&key=${apiKey}&comment=${SCRIPT}` ); const data = await resp.json(); if (data.error) { console.warn('[TSA] item lookup error:', data.error); return nameToId; // return whatever we have cached } const items = data.items || {}; for (const [id, item] of Object.entries(items)) { const name = (item.name || '').toLowerCase(); nameToId[name] = parseInt(id, 10); } save(CACHE_KEY, JSON.stringify(nameToId)); save(CACHE_TIME, Date.now()); } catch (e) { console.warn('[TSA] failed to fetch item IDs:', e); } return nameToId; } async function fetchUserStocks(apiKey) { // Confirmed API response shapes (from live inspection): // // GET /v2/torn/stocks: // { stocks: { "1": { acronym, name, current_price, ... }, ... } } // // GET /v2/user/stocks: // { stocks: { "4": { id, shares, transactions:[...], bonus:{...} }, ... } } // NOTE: no acronym in user/stocks — must join on numeric id from torn/stocks. const [userData, tornData] = await Promise.all([ apiFetch('/user/stocks', apiKey), apiFetch('/torn/stocks', apiKey), ]); // ── Build name→ticker lookup from STOCK_DATA ───────────────────────────── // /torn/stocks returns name but no acronym field, so we match by name. const nameToTicker = {}; for (const s of STOCK_DATA) { nameToTicker[s.name.toLowerCase()] = s.ticker; } // ── Build id→ticker and ticker→price maps from /torn/stocks ────────────── const idToTicker = {}; // "1" → "TSB" const prices = {}; // "TSB" → 1183.85 const tornStocksRaw = tornData.stocks || {}; const tornStocksArr = Array.isArray(tornStocksRaw) ? tornStocksRaw : Object.entries(tornStocksRaw).map(([id, s]) => ({ ...s, _id: id })); for (const s of tornStocksArr) { const id = String(s.id || s._id || ''); // Try acronym field first, then name match const acronym = (s.acronym || '').toUpperCase(); const ticker = acronym || nameToTicker[(s.name || '').toLowerCase()] || ''; if (id && ticker) idToTicker[id] = ticker; // price is nested: { market: { price: 1184.08, ... } } const market = (typeof s.market === 'object' && s.market) ? s.market : {}; const price = parseFloat(market.price || market.current_price || s.price || s.current_price || 0); if (ticker && price > 0) prices[ticker] = price; } // ── DOM price reader — read prices directly from the rendered stock table ── // Each row: [name cell with "(TCK) Stock Name"] [price cell "NNN.NN"] ... // The name cell is the first td that contains the (TICKER) pattern. // The price cell is the NEXT sibling td after the name cell. const domRows = document.querySelectorAll('#stockmarketroot table tr'); for (const row of domRows) { const cells = [...row.querySelectorAll('td')]; if (cells.length < 2) continue; for (let i = 0; i < cells.length - 1; i++) { const cellText = cells[i].textContent || ''; const m = cellText.match(/\(([A-Z]{2,4})\)/); if (!m) continue; const ticker = m[1]; // Price cell: take first continuous number sequence (ignores 24h change text) const priceCell = cells[i + 1]; if (!priceCell) break; // Remove commas, get first float-like number from the cell const numMatch = (priceCell.textContent || '').replace(/,/g, '').match(/[\d]+\.?\d*/); const priceVal = numMatch ? parseFloat(numMatch[0]) : 0; if (priceVal > 0) { prices[ticker] = priceVal; } break; // found ticker in this row, move on } } // ── Build ticker→shares map from /user/stocks ───────────────────────────── // Each entry: { id, shares (number = total held), bonus:{increment,...} } const holdings = {}; const userStocksRaw = userData.stocks || {}; const userStocksArr = Array.isArray(userStocksRaw) ? userStocksRaw : Object.entries(userStocksRaw).map(([id, s]) => ({ ...s, _id: id })); for (const s of userStocksArr) { const id = String(s.id || s._id || ''); const ticker = idToTicker[id]; if (!ticker) continue; // shares is a plain number = total shares held across all blocks const n = typeof s.shares === 'number' ? s.shares : 0; if (n > 0) holdings[ticker] = (holdings[ticker] || 0) + n; } // ── DOM fallback: read Owned column from the stock table ────────────────── // Catches any stocks missing from API (e.g. if key lacks full access). // Table structure: Name | Price | 24h | Owned | Dividend // Name cell contains "(TCK) Stock Name", Owned cell has investment + share count. if (Object.keys(holdings).length === 0) { const tableRows = document.querySelectorAll('#stockmarketroot table tr'); for (const row of tableRows) { const cells = row.querySelectorAll('td'); if (cells.length < 4) continue; const nameText = cells[0]?.textContent || ''; const tickerMatch = nameText.match(/\(([A-Z]{2,4})\)/); if (!tickerMatch) continue; const ticker = tickerMatch[1]; const ownedText = cells[3]?.textContent.replace(/[$,]/g, '') || ''; const nums = ownedText.match(/\d+/g); if (nums && nums.length >= 2) { const shares = parseInt(nums[nums.length - 1], 10); if (!isNaN(shares) && shares > 0) holdings[ticker] = shares; } } } return { holdings, prices }; } // ─── Scoring ────────────────────────────────────────────────────────────────── async function buildScores(apiKey) { const { holdings, prices } = await fetchUserStocks(apiKey); // Resolve item names to IDs via API (cached for 24h), then fetch market prices. // This avoids hardcoding item IDs which can be wrong or change. const itemStocks = STOCK_DATA.filter(s => s.payoutType === 'item'); const itemNames = [...new Set(itemStocks.map(s => s.payoutItemName).filter(Boolean))]; const nameToId = await resolveItemIds(apiKey, itemNames); // Assign resolved IDs back to stock data (in-memory only, not mutating STOCK_DATA const) const resolvedIds = {}; // ticker -> resolved item ID for (const stock of itemStocks) { if (stock.payoutItemName) { const resolvedId = nameToId[stock.payoutItemName.toLowerCase()]; if (resolvedId) resolvedIds[stock.ticker] = resolvedId; else if (stock.payoutItemId) resolvedIds[stock.ticker] = stock.payoutItemId; // fallback to hardcoded } else if (stock.payoutItemId) { resolvedIds[stock.ticker] = stock.payoutItemId; } } // Parallel item price fetches using resolved IDs const uniqueIds = [...new Set(Object.values(resolvedIds))].filter(Boolean); const itemPrices = {}; await Promise.all(uniqueIds.map(async id => { itemPrices[id] = await fetchItemPrice(apiKey, id); })); const ignoredTickers = getIgnored(); const excludeMap = { nerve: getExNerve(), energy: getExEnergy(), happy: getExHappy(), other: getExOther(), passive: getExPassive(), }; const rows = []; for (const stock of STOCK_DATA) { const { ticker, name, payoutType, payoutInterval, perIncrQty, payoutItemId, payoutCashValue, payoutDesc, increments } = stock; const ignored = ignoredTickers.includes(ticker); const excluded = excludeMap[payoutType] || false; const sharesHeld = holdings[ticker] || 0; const price = prices[ticker] || 0; // Resolve per-increment payout value let incrValue = 0; if (payoutType === 'item') { const resolvedId = resolvedIds[ticker] || payoutItemId; const unitPrice = resolvedId ? (itemPrices[resolvedId] || 0) : 0; incrValue = getPayoutOverride(ticker, unitPrice * perIncrQty); } else { incrValue = getPayoutOverride(ticker, payoutCashValue); } // Daily value = payout per interval / interval length const dailyValue = payoutInterval > 0 ? incrValue / payoutInterval : 0; for (let i = 0; i < increments.length; i++) { const { incr, threshold: defThresh } = increments[i]; const threshold = getThreshOverride(ticker, incr, defThresh); const prevThresh = i > 0 ? getThreshOverride(ticker, increments[i-1].incr, increments[i-1].threshold) : 0; // INCREMENTAL cost — only the shares needed for this block, not cumulative const incrShares = threshold - prevThresh; const incrCost = incrShares * price; const sharesNeeded = Math.max(0, threshold - sharesHeld); const costToComplete = sharesNeeded * price; // Determine status let status; if (ignored) status = 'ignored'; else if (sharesHeld >= threshold) status = 'held'; else if (sharesHeld > prevThresh) status = 'partial'; else status = (i === 0 || sharesHeld >= prevThresh) ? 'next' : 'future'; // Budget filter const budget = getBudget(); const budgetMax = budget > 0 ? budget * (getBudgetPct() / 100) : Infinity; const overBudget = budget > 0 && costToComplete > budgetMax; const scoreable = !ignored && !excluded && status !== 'held' && dailyValue > 0 && incrCost > 0; const dailyROI = scoreable ? dailyValue / incrCost : 0; rows.push({ ticker, name, incr, threshold, prevThresh, incrShares, incrCost, sharesHeld, sharesNeeded, costToComplete, price, payoutType, payoutInterval, payoutDesc, incrValue, dailyValue, status, ignored, excluded, scoreable, dailyROI, overBudget, score: 0, }); } } // Normalise to 0–10 const scoreable = rows.filter(r => r.scoreable); const maxROI = scoreable.length ? Math.max(...scoreable.map(r => r.dailyROI)) : 1; for (const r of rows) { r.score = (r.scoreable && maxROI > 0) ? Math.round((r.dailyROI / maxROI) * 100) / 10 : 0; } // Sort: scoreable by score desc → held → everything else rows.sort((a, b) => { if (a.scoreable && b.scoreable) return b.score - a.score; if (a.scoreable) return -1; if (b.scoreable) return 1; if (a.status === 'held' && b.status !== 'held') return -1; if (a.status !== 'held' && b.status === 'held') return 1; return 0; }); return { rows, holdings, prices }; } // ─── UI build ───────────────────────────────────────────────────────────────── function buildUI() { const root = document.createElement('div'); root.id = 'tsa-root'; if (load('dashboard_collapsed', '0') === '1') root.classList.add('collapsed'); root.innerHTML = `