Greasy Fork

Greasy Fork is available in English.

Torn Stock Advisor

Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table.

当前为 2026-05-01 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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
// ==/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 = `
      <div id="tsa-header">
        <span class="tsa-title">Torn Stock Advisor <span class="tsa-version">v1.4.1</span></span>
        <span id="tsa-updated" class="tsa-updated">Not loaded</span>
        <button class="tsa-btn-secondary" id="tsa-cfg-toggle">⚙ Config</button>
        <button class="tsa-btn-primary"   id="tsa-refresh-btn">↻ Refresh</button>
        <button class="tsa-btn-secondary" id="tsa-collapse-btn">${load('dashboard_collapsed','0')==='1'?'▼':'▲'}</button>
      </div>

      <div id="tsa-config-strip">
        <span>API Key: <span id="tsa-key-status" class="tsa-key-bad">✗ Not set</span></span>
        <span class="tsa-sep">|</span>
        <span>Ignored: <span id="tsa-strip-ignored" style="color:#ff7700">None</span></span>
        <span class="tsa-sep">|</span>
        <span>Excl: <span id="tsa-strip-excl" style="color:#ff7700">None</span></span>
        <span class="tsa-sep">|</span>
        <span id="tsa-strip-budget-wrap">Budget: <span id="tsa-strip-budget" style="color:#ff7700">Off</span></span>
        <span class="tsa-sep">|</span>
        <span>Refresh: <span id="tsa-strip-refresh" style="color:#aaa">5 min</span></span>
      </div>

      <div id="tsa-config-panel">
        <span class="tsa-cfg-label">API Key — requires <strong style="color:#ff7700">Stocks</strong> access level</span>
        <div class="tsa-cfg-row">
          <label>Torn API Key</label>
          <input class="tsa-cfg-input" id="tsa-cfg-apikey" type="password" placeholder="Enter API key…" style="width:220px" />
          <button class="tsa-btn-primary" id="tsa-cfg-save-key">Save Key</button>
        </div>
        <div class="tsa-cfg-note">Stored locally only — never sent except to api.torn.com.</div>

        <span class="tsa-cfg-label">Ignore stocks (comma-separated tickers — removed from scoring and table)</span>
        <div class="tsa-cfg-row">
          <label>Ignored tickers</label>
          <input class="tsa-cfg-input" id="tsa-cfg-ignored" type="text" placeholder="e.g. TCT, GRN" style="width:220px" />
        </div>

        <span class="tsa-cfg-label">Exclude payout types from scoring (still visible in table)</span>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-nerve">   <label for="tsa-ex-nerve">Nerve payouts (e.g. CBD)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-energy">  <label for="tsa-ex-energy">Energy payouts (e.g. MCS)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-happy">   <label for="tsa-ex-happy">Happiness payouts (e.g. EVL)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-other">   <label for="tsa-ex-other">Other non-item payouts (points, properties — PTS, LSC, HRG)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-passive"> <label for="tsa-ex-passive">Passive stocks (no active payout — TCP, TCI, WLT etc.)</label></div>

        <span class="tsa-cfg-label">Budget filter</span>
        <div class="tsa-cfg-row">
          <label>Available cash ($)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-budget" type="number" min="0" placeholder="e.g. 650000000" style="width:160px" />
          <span style="font-size:10px;color:#555">Leave blank to disable budget filter</span>
        </div>
        <div class="tsa-cfg-row">
          <label>Max cost (% of budget)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-budget-pct" type="number" min="100" max="500" style="width:70px" />
          <span style="font-size:10px;color:#555">e.g. 110 = up to 10% above budget</span>
        </div>
        <div class="tsa-cfg-row">
          <label>Over-budget blocks</label>
          <select class="tsa-cfg-input" id="tsa-cfg-budget-mode" style="width:180px">
            <option value="grey">Grey out (still visible in table)</option>
            <option value="hide">Hide from recommendations</option>
            <option value="show">Show everything (filter off)</option>
          </select>
        </div>

        <span class="tsa-cfg-label">Display</span>
        <div class="tsa-cfg-row">
          <label>Recommendation cards to show</label>
          <input class="tsa-cfg-input" id="tsa-cfg-topn" type="number" min="1" max="10" style="width:60px" />
        </div>
        <div class="tsa-cfg-row">
          <label>Auto-refresh (minutes)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-refresh" type="number" min="1" max="60" style="width:60px" />
        </div>

        <span class="tsa-cfg-label">Per-stock payout value overrides &amp; block thresholds
          <span style="font-size:9px;color:#444;text-transform:none;margin-left:6px">Item stocks use live market price automatically; enter $ override to fix the value. Cash stocks use defaults from community data.</span>
        </span>
        <div id="tsa-cfg-accordions"></div>

        <div style="margin-top:10px;display:flex;gap:8px">
          <button class="tsa-btn-primary" id="tsa-cfg-save-all">Save All Config</button>
          <button class="tsa-btn-secondary" id="tsa-cfg-reset">Reset Defaults</button>
        </div>
      </div>

      <div id="tsa-stats-bar">
        <div class="tsa-stat"><span class="tsa-stat-label">Active Blocks</span><span class="tsa-stat-value" id="tsa-s-active">—</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Partial</span><span class="tsa-stat-value" id="tsa-s-partial" style="color:#ffaa00">—</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Daily Payout</span><span class="tsa-stat-value" id="tsa-s-daily">—</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Best Daily ROI</span><span class="tsa-stat-value" id="tsa-s-roi">—</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Stocks Tracked</span><span class="tsa-stat-value">${STOCK_DATA.length}</span></div>
      </div>

      <div id="tsa-body">
        <div id="tsa-error"></div>
        <div id="tsa-loading"><span class="tsa-spinner"></span>Loading stock data…</div>
        <div id="tsa-content" style="display:none">

          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-recs-title">Top Recommendations</div>
            <div id="tsa-recs-content"><div id="tsa-cards"></div></div>
          </div>

          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-hold-title">Your Holdings</div>
            <div id="tsa-hold-content"></div>
          </div>

          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-table-title">All Stocks — Full Rankings</div>
            <div id="tsa-table-content">
              <table class="tsa-table">
                <thead><tr>
                  <th style="width:28px">#</th>
                  <th style="min-width:200px">Stock</th>
                  <th style="width:50px">Type</th>
                  <th style="min-width:90px; text-align:right">Payout</th>
                  <th style="width:48px; text-align:center">Cycle</th>
                  <th style="min-width:75px; text-align:right">Held</th>
                  <th style="min-width:85px; text-align:right">Cost</th>
                  <th style="min-width:75px; text-align:right">Daily</th>
                  <th style="min-width:100px; text-align:right">ROI · Score</th>
                  <th style="width:65px; text-align:center">Status</th>
                </tr></thead>
                <tbody id="tsa-tbody"></tbody>
              </table>
            </div>
          </div>

        </div>
      </div>

      <div id="tsa-footer">
        <span>Torn Stock Advisor · TheOddSod (2640064)</span>
        <span>Scores = incremental daily ROI · Item values via live market</span>
      </div>
    `;
    return root;
  }

  // ─── Populate config ──────────────────────────────────────────────────────────

  function populateConfig(root) {
    root.querySelector('#tsa-cfg-apikey').value   = getApiKey();
    root.querySelector('#tsa-cfg-ignored').value  = load('ignored', '');
    root.querySelector('#tsa-cfg-refresh').value  = getRefreshMins();
    root.querySelector('#tsa-cfg-budget').value      = getBudget() || '';
    root.querySelector('#tsa-cfg-budget-pct').value  = getBudgetPct();
    root.querySelector('#tsa-cfg-budget-mode').value = getBudgetMode();
    root.querySelector('#tsa-cfg-topn').value     = getTopN();
    root.querySelector('#tsa-ex-nerve').checked   = getExNerve();
    root.querySelector('#tsa-ex-energy').checked  = getExEnergy();
    root.querySelector('#tsa-ex-happy').checked   = getExHappy();
    root.querySelector('#tsa-ex-other').checked   = getExOther();
    root.querySelector('#tsa-ex-passive').checked = getExPassive();

    const container = root.querySelector('#tsa-cfg-accordions');
    container.innerHTML = '';

    for (const stock of STOCK_DATA) {
      const { ticker, name, payoutType, payoutInterval, payoutDesc, increments } = stock;
      const iLabel = payoutInterval > 0 ? `every ${payoutInterval}d` : 'passive';

      const accord = document.createElement('div');
      accord.className = 'tsa-accord';

      const hdr = document.createElement('div');
      hdr.className = 'tsa-accord-hdr';
      hdr.innerHTML = `
        <span>
          <span class="tsa-accord-ticker">${ticker}</span>
          ${name}
          <span style="font-size:10px;color:#555;margin-left:6px">${iLabel}</span>
        </span>
        <span class="tsa-accord-arrow">▾</span>
      `;

      const body = document.createElement('div');
      body.className = 'tsa-accord-body';

      // Per-increment payout value override
      const savedPayout = load(`payout_${ticker}`, '');
      const placeholder = payoutType === 'item' ? 'auto (live market)' : (stock.payoutCashValue || '0');
      const payoutRow = document.createElement('div');
      payoutRow.className = 'tsa-incr-row';
      payoutRow.innerHTML = `
        <label>Per-incr value ($)</label>
        <input class="tsa-cfg-input tsa-payout-inp" data-ticker="${ticker}"
               type="number" value="${savedPayout}" placeholder="${placeholder}" style="width:130px" />
        <span class="tsa-incr-note">${payoutDesc}</span>
      `;
      body.appendChild(payoutRow);

      // Per-block threshold overrides
      for (const { incr, threshold } of increments) {
        const saved = getThreshOverride(ticker, incr, threshold);
        const row = document.createElement('div');
        row.className = 'tsa-incr-row';
        row.innerHTML = `
          <label>Block ${incr} threshold</label>
          <input class="tsa-cfg-input tsa-thresh-inp" data-ticker="${ticker}" data-incr="${incr}"
                 type="number" value="${saved}" style="width:130px" />
          <span class="tsa-incr-note">default: ${fmtShares(threshold)} shares total</span>
        `;
        body.appendChild(row);
      }

      hdr.addEventListener('click', () => {
        hdr.classList.toggle('open');
        body.classList.toggle('open');
      });

      accord.appendChild(hdr);
      accord.appendChild(body);
      container.appendChild(accord);
    }
  }

  // ─── Config strip ─────────────────────────────────────────────────────────────

  function updateStrip(root) {
    const key = getApiKey();
    const el  = root.querySelector('#tsa-key-status');
    el.textContent = key ? '✓ Connected' : '✗ Not set';
    el.className   = key ? 'tsa-key-ok'  : 'tsa-key-bad';

    const ig = getIgnored();
    root.querySelector('#tsa-strip-ignored').textContent = ig.length ? ig.join(', ') : 'None';

    const excl = [];
    if (getExNerve())   excl.push('Nerve');
    if (getExEnergy())  excl.push('Energy');
    if (getExHappy())   excl.push('Happy');
    if (getExOther())   excl.push('Other');
    if (getExPassive()) excl.push('Passive');
    root.querySelector('#tsa-strip-excl').textContent   = excl.length ? excl.join(', ') : 'None';
    root.querySelector('#tsa-strip-refresh').textContent = getRefreshMins() + ' min';
    const budget = getBudget();
    const budgetEl = root.querySelector('#tsa-strip-budget');
    if (budgetEl) {
      budgetEl.textContent = budget > 0
        ? fmtMoney(budget) + ' (' + getBudgetPct() + '%)'
        : 'Off';
    }
  }

  // ─── Render: recommendation cards ────────────────────────────────────────────

  function renderCards(root, rows) {
    const container = root.querySelector('#tsa-cards');
    container.innerHTML = '';
    const budgetMode = getBudgetMode();
    // De-duplicate per stock, apply budget filter to recommendation cards
    const seenTickers = new Set();
    const dedupedRows = [];
    for (const r of rows.filter(r => r.scoreable && r.score > 0)) {
      // In 'hide' mode, exclude over-budget blocks from cards entirely
      if (budgetMode === 'hide' && r.overBudget) continue;
      if (!seenTickers.has(r.ticker)) {
        seenTickers.add(r.ticker);
        dedupedRows.push(r);
      }
    }
    const topRows = dedupedRows.slice(0, getTopN());

    if (!topRows.length) {
      container.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0">No scoreable blocks — check config or API key.</div>';
      return;
    }

    topRows.forEach((r, i) => {
      const rank = i + 1;
      const rankCls = rank === 1 ? 'tsa-rank-gold' : rank === 2 ? 'tsa-rank-silver' : rank === 3 ? 'tsa-rank-bronze' : '';
      const iLabel  = r.payoutInterval > 0 ? `every ${r.payoutInterval} days` : '';

      let partialHtml = '';
      if (r.status === 'partial') {
        const pct = Math.round(((r.sharesHeld - r.prevThresh) / r.incrShares) * 100);
        partialHtml = `<div class="tsa-card-partial">▶ Already hold ${fmtShares(r.sharesHeld - r.prevThresh)} / ${fmtShares(r.incrShares)} shares (${pct}% there)</div>`;
      }

      const card = document.createElement('div');
      card.className = 'tsa-card';
      card.innerHTML = `
        <div class="tsa-card-rank ${rankCls}">#${rank}</div>
        <div class="tsa-card-head">
          <span class="tsa-ticker">${r.ticker}</span> &nbsp;${r.name}
        </div>
        <div class="tsa-card-sub">
          <span class="tsa-badge tsa-badge-info">Block ${r.incr}</span>
        </div>
        <div class="tsa-card-line">Payout: <strong>${r.incrValue > 0 ? fmtMoney(r.incrValue) : 'set in config'}</strong> ${iLabel}</div>
        <div class="tsa-card-line">Need: <strong>${fmtShares(r.sharesNeeded)} shares</strong> &nbsp;·&nbsp; Cost: <strong>${fmtMoney(r.costToComplete)}</strong></div>
        <div class="tsa-card-roi">Daily ROI: ${fmtROI(r.dailyValue, r.incrCost)} &nbsp;·&nbsp; Score: ${r.score.toFixed(1)}/10</div>
        ${r.overBudget ? `<div style="font-size:10px;color:#ff4444;margin-top:3px">⚠ Over budget (${fmtMoney(r.costToComplete)} needed)</div>` : ''}
        ${partialHtml}
      `;
      container.appendChild(card);
    });
  }

  // ─── Render: holdings ────────────────────────────────────────────────────────

  function renderHoldings(root, rows) {
    const content = root.querySelector('#tsa-hold-content');
    content.innerHTML = '';

    const held    = rows.filter(r => r.status === 'held'    && !r.ignored);
    const partial = rows.filter(r => r.status === 'partial' && !r.ignored);

    if (!held.length && !partial.length) {
      content.innerHTML = '<div style="color:#555;font-size:12px;padding:6px 0">No active or partial blocks detected.</div>';
      return;
    }

    if (held.length) {
      const lbl = document.createElement('div');
      lbl.className = 'tsa-hold-sublabel';
      lbl.textContent = 'Active blocks';
      content.appendChild(lbl);

      // Group by ticker — show one row per stock with total increment count + total daily value
      const byTicker = {};
      for (const r of held) {
        if (!byTicker[r.ticker]) byTicker[r.ticker] = { r, count: 0, totalDaily: 0 };
        byTicker[r.ticker].count++;
        byTicker[r.ticker].totalDaily += r.dailyValue;
      }

      for (const { r, count, totalDaily } of Object.values(byTicker)) {
        const iLabel = r.payoutInterval > 0 ? `every ${r.payoutInterval}d` : 'passive';
        const row = document.createElement('div');
        row.className = 'tsa-hold-row';
        row.innerHTML = `
          <span class="tsa-ticker">${r.ticker}</span>
          <span class="tsa-hold-name">${r.name}</span>
          <span class="tsa-badge tsa-badge-ok">${count} block${count > 1 ? 's' : ''} ✓</span>
          <span class="tsa-hold-val">${totalDaily > 0 ? fmtMoney(totalDaily) + '/day' : '—'}</span>
          <span style="color:#555;font-size:10px">${fmtShares(r.sharesHeld)} held &nbsp;·&nbsp; ${iLabel}</span>
        `;
        content.appendChild(row);
      }
    }

    if (partial.length) {
      const lbl = document.createElement('div');
      lbl.className = 'tsa-hold-sublabel';
      lbl.textContent = 'Partial — working toward next block';
      content.appendChild(lbl);

      for (const r of partial) {
        const pct = Math.round(((r.sharesHeld - r.prevThresh) / r.incrShares) * 100);
        const row = document.createElement('div');
        row.className = 'tsa-hold-row';
        row.innerHTML = `
          <span class="tsa-ticker">${r.ticker}</span>
          <span class="tsa-hold-name">${r.name} <span style="color:#555">block ${r.incr}</span></span>
          <span class="tsa-badge tsa-badge-warn">${pct}%</span>
          <span class="tsa-hold-partial-txt">${fmtShares(r.sharesHeld)} / ${fmtShares(r.threshold)} &nbsp;·&nbsp; need ${fmtShares(r.sharesNeeded)} more &nbsp;·&nbsp; ${fmtMoney(r.costToComplete)}</span>
        `;
        content.appendChild(row);
      }
    }
  }

  // ─── Render: table ───────────────────────────────────────────────────────────

  function renderTable(root, rows) {
    const tbody = root.querySelector('#tsa-tbody');
    tbody.innerHTML = '';
    let rankIdx = 0;

    for (const r of rows) {
      const tr = document.createElement('tr');
      if (r.ignored)                       tr.classList.add('tsa-row-ignored');
      else if (r.payoutType === 'passive')  tr.classList.add('tsa-row-passive');
      else if (r.overBudget && getBudgetMode() === 'grey') tr.classList.add('tsa-row-overbudget');

      const active = r.scoreable && r.score > 0;
      if (active) rankIdx++;

      const rCls = rankIdx === 1 ? 'tsa-rank-gold'
                 : rankIdx === 2 ? 'tsa-rank-silver'
                 : rankIdx === 3 ? 'tsa-rank-bronze' : '';
      const rankCell = active
        ? `<span class="tsa-rank-num ${rCls}">${rankIdx}</span>`
        : `<span class="tsa-rank-num" style="color:#2a2a4a">—</span>`;

      const typeBadgeMap = {
        cash: 'tsa-badge-cash', item: 'tsa-badge-item',
        energy: 'tsa-badge-energy', nerve: 'tsa-badge-nerve',
        happy: 'tsa-badge-happy', other: 'tsa-badge-other',
        passive: 'tsa-badge-passive',
      };
      const typeBadge = `<span class="tsa-badge ${typeBadgeMap[r.payoutType] || 'tsa-badge-muted'}">${r.payoutType}</span>`;

      let statusBadge;
      if (r.ignored)              statusBadge = `<span class="tsa-badge tsa-badge-muted">Ignored</span>`;
      else if (r.excluded)        statusBadge = `<span class="tsa-badge tsa-badge-muted">Excl.</span>`;
      else if (r.status === 'held')    statusBadge = `<span class="tsa-badge tsa-badge-ok">Active ✓</span>`;
      else if (r.status === 'partial') statusBadge = `<span class="tsa-badge tsa-badge-warn">Partial</span>`;
      else if (r.status === 'next')    statusBadge = `<span class="tsa-badge tsa-badge-info">Next</span>`;
      else                             statusBadge = `<span class="tsa-badge tsa-badge-muted">Future</span>`;

      const iLabel   = r.payoutInterval > 0 ? `${r.payoutInterval}d` : 'passive';
      const payoutCell = r.incrValue > 0
        ? `<span style="color:#ccc">${fmtMoney(r.incrValue)}</span>`
        : `<span style="color:#333">—</span>`;
      const heldCell = r.status === 'held'
        ? `<span style="color:#44ee88">${fmtShares(r.sharesHeld)} ✓</span>`
        : `<span style="color:#666">${fmtShares(r.sharesHeld)}</span>`;
      const overBudgetBadge = (r.overBudget && getBudgetMode() !== 'show' && r.status !== 'held')
        ? ` <span style="color:#ff4444;font-size:9px">▲</span>` : '';
      const costCell = r.status === 'held'
        ? `<span style="color:#333">—</span>`
        : r.costToComplete > 0
          ? `<span style="color:${r.overBudget ? '#ff4444' : r.status === 'partial' ? '#ffaa00' : '#aaa'}">${fmtMoney(r.costToComplete)}${overBudgetBadge}</span>`
          : `<span style="color:#333">—</span>`;
      const dailyCell = r.dailyValue > 0
        ? `<span style="color:#44ee88">${fmtMoney(r.dailyValue)}</span>`
        : `<span style="color:#222">—</span>`;
      const roiCell = active
        ? `<span style="color:#44ee88; font-size:10px">${fmtROI(r.dailyValue, r.incrCost)}</span>
           <span style="color:#ff7700; font-weight:bold; margin-left:4px">${r.score.toFixed(1)}</span>`
        : `<span style="color:#222">—</span>`;

      tr.innerHTML = `
        <td style="text-align:center">${rankCell}</td>
        <td>
          <span class="tsa-ticker" style="margin-right:4px">${r.ticker}</span>
          <span style="color:#888; font-size:11px">${r.name}</span>
          <span style="color:#444; font-size:10px; margin-left:4px">B${r.incr}</span>
        </td>
        <td>${typeBadge}</td>
        <td style="text-align:right">${payoutCell}</td>
        <td style="text-align:center; color:#555; font-size:10px">${iLabel}</td>
        <td style="text-align:right">${heldCell}</td>
        <td style="text-align:right">${costCell}</td>
        <td style="text-align:right">${dailyCell}</td>
        <td style="text-align:right">${roiCell}</td>
        <td style="text-align:center">${statusBadge}</td>
      `;
      tbody.appendChild(tr);
    }
  }


  // ─── Stats bar ────────────────────────────────────────────────────────────────

  function updateStats(root, rows) {
    const active  = rows.filter(r => r.status==='held'    && !r.ignored).length;
    const partial = rows.filter(r => r.status==='partial' && !r.ignored).length;
    const daily   = rows.filter(r => r.status==='held' && !r.ignored && !r.excluded)
                        .reduce((s, r) => s + r.dailyValue, 0);
    const best    = rows.find(r => r.scoreable && r.score > 0);

    root.querySelector('#tsa-s-active').textContent  = active;
    root.querySelector('#tsa-s-partial').textContent  = partial;
    root.querySelector('#tsa-s-daily').textContent    = fmtMoney(daily);
    root.querySelector('#tsa-s-roi').textContent      = best ? fmtROI(best.dailyValue, best.incrCost) : '—';
  }

  // ─── Load & render ────────────────────────────────────────────────────────────

  async function loadAndRender(root) {
    const apiKey = getApiKey();
    const errEl  = root.querySelector('#tsa-error');
    const loadEl = root.querySelector('#tsa-loading');
    const contEl = root.querySelector('#tsa-content');

    errEl.style.display  = 'none';
    loadEl.style.display = 'block';
    contEl.style.display = 'none';

    if (!apiKey) {
      loadEl.style.display = 'none';
      errEl.style.display  = 'block';
      errEl.textContent    = '⚠ No API key set — open ⚙ Config and enter your Torn API key (Stocks access required).';
      return;
    }

    try {
      const { rows } = await buildScores(apiKey);
      loadEl.style.display = 'none';
      contEl.style.display = 'block';
      renderCards(root, rows);
      renderHoldings(root, rows);
      renderTable(root, rows);
      updateStats(root, rows);
      root.querySelector('#tsa-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
    } catch (e) {
      loadEl.style.display = 'none';
      errEl.style.display  = 'block';
      errEl.textContent    = '⚠ Error: ' + e.message;
      console.error('[TSA]', e);
    }
  }

  // ─── Event wiring ─────────────────────────────────────────────────────────────

  function wireEvents(root) {
    root.querySelector('#tsa-collapse-btn').addEventListener('click', () => {
      const c = root.classList.toggle('collapsed');
      root.querySelector('#tsa-collapse-btn').textContent = c ? '▼' : '▲';
      save('dashboard_collapsed', c ? '1' : '0');
    });

    root.querySelector('#tsa-cfg-toggle').addEventListener('click', () => {
      root.querySelector('#tsa-config-panel').classList.toggle('open');
    });

    root.querySelector('#tsa-cfg-save-key').addEventListener('click', () => {
      save('api_key', root.querySelector('#tsa-cfg-apikey').value.trim());
      updateStrip(root);
      loadAndRender(root);
    });

    root.querySelector('#tsa-cfg-save-all').addEventListener('click', () => {
      save('ignored',    root.querySelector('#tsa-cfg-ignored').value);
      save('ex_nerve',   root.querySelector('#tsa-ex-nerve').checked);
      save('ex_energy',  root.querySelector('#tsa-ex-energy').checked);
      save('ex_happy',   root.querySelector('#tsa-ex-happy').checked);
      save('ex_other',   root.querySelector('#tsa-ex-other').checked);
      save('ex_passive', root.querySelector('#tsa-ex-passive').checked);
      save('refresh_mins', root.querySelector('#tsa-cfg-refresh').value);
      save('budget',       root.querySelector('#tsa-cfg-budget').value);
      save('budget_pct',   root.querySelector('#tsa-cfg-budget-pct').value);
      save('budget_mode',  root.querySelector('#tsa-cfg-budget-mode').value);
      save('top_n',        root.querySelector('#tsa-cfg-topn').value);

      root.querySelectorAll('.tsa-payout-inp').forEach(el => {
        save(`payout_${el.dataset.ticker}`, el.value);
      });
      root.querySelectorAll('.tsa-thresh-inp').forEach(el => {
        save(`thresh_${el.dataset.ticker}_${el.dataset.incr}`, el.value);
      });

      updateStrip(root);
      resetTimer(root);
      loadAndRender(root);
    });

    root.querySelector('#tsa-cfg-reset').addEventListener('click', () => {
      if (!confirm('Reset all config to defaults? Your API key will be kept.')) return;
      const key = getApiKey();
      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);
        }
      }
      ['ignored','ex_nerve','ex_energy','ex_happy','ex_other','ex_passive','refresh_mins','top_n'].forEach(k => {
        GM_setValue(PREFIX + k, null);
      });
      save('api_key', key);
      populateConfig(root);
      updateStrip(root);
      loadAndRender(root);
    });

    root.querySelector('#tsa-refresh-btn').addEventListener('click', () => loadAndRender(root));

    // Sections: recs and holdings open by default, table collapsed
    wireCollapse(root.querySelector('#tsa-recs-title'),   root.querySelector('#tsa-recs-content'),   'collapse_recs',      'open');
    wireCollapse(root.querySelector('#tsa-hold-title'),   root.querySelector('#tsa-hold-content'),   'collapse_holdings',  'open');
    wireCollapse(root.querySelector('#tsa-table-title'),  root.querySelector('#tsa-table-content'),  'collapse_table',     'collapsed');
  }

  // ─── Timer & injection ────────────────────────────────────────────────────────

  function resetTimer(root) {
    clearInterval(window._tsaRefreshTimer);
    window._tsaRefreshTimer = setInterval(() => loadAndRender(root), getRefreshMins() * 60 * 1000);
  }

  function inject() {
    if (!location.href.includes('sid=stocks')) return;
    if (document.getElementById('tsa-root')) return;

    let attempts = 0;
    const tryInsert = setInterval(() => {
      attempts++;
      if (attempts > 30) { clearInterval(tryInsert); return; }

      // #stockmarketroot is the confirmed container for all stock market content.
      // We insert our panel as its first child, above the Stocks Filter and table.
      const smRoot = document.getElementById('stockmarketroot');
      if (!smRoot || !smRoot.firstChild) return;

      clearInterval(tryInsert);

      const root = buildUI();
      smRoot.insertBefore(root, smRoot.firstChild);
      populateConfig(root);
      updateStrip(root);
      wireEvents(root);
      loadAndRender(root);
      resetTimer(root);
    }, 500);
  }

  inject();
  window.addEventListener('hashchange', () => { setTimeout(inject, 400); });

})();