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-02 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Torn Stock Advisor
// @namespace    torn_stock_advisor
// @version      1.7.0
// @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.6.9 — Polish: swap advisor sell groups now visually separated with
//           a border-top divider and more vertical breathing room.
//           Section margins increased further throughout.
// v1.6.8 — Polish: improved vertical spacing throughout — section gaps,
//           card padding, swap advisor rows, stats bar, body padding.
// v1.6.7 — Fix: item price fetch switched to v2 market endpoint
//           (/v2/market/{id}?selections=itemmarket) — v1 returns error 23.
//           Fixes LAG (Lawyer's Business Card) and all other item stocks.
// v1.6.6 — Fix: LAG item name corrected to "Lawyer's Business Card" (apostrophe
//           was missing). Item ID hardcoded as 368 (confirmed from market URL).
// v1.6.5 — Fix: points market endpoint corrected to v1 market/?selections=pointsmarket
//           matching armoury script — response is object keyed by listing ID,
//           iterate to find cheapest .cost field. PTS now prices correctly.
// v1.6.4 — Feature: PTS (PointLess) now auto-prices using live points market.
//           Fetches cheapest points price in parallel with other API calls.
//           PTS type changed from 'other' to 'cash' so it scores by default.
// v1.6.3 — Polish: holdings grid column widths tightened for better alignment.
// v1.6.2 — Polish: Greasy Fork ID set (576155). Holdings section uses CSS grid
//           for aligned columns. Vertical spacing improved throughout.
// v1.6.1 — Feature: "Never sell" list in config — stocks added here never
//           appear as sell candidates in the Swap Advisor.
// v1.6.0 — Fix: ASS (Alcoholics Synonymous) interval corrected to 7 days
//           (was 31 days). Daily value now ~4.4x higher.
// v1.5.9 — UI: swap advisor grouped by sell action with buy options labelled
//           "Buy" / "↳ or" so it's clear each group is one sell, not multiple.
// v1.5.8 — UI: swap advisor redesigned as readable card rows — each row reads
//           as a plain sentence rather than a dense data table.
// v1.5.7 — Fix: recommendation card click now simulates a click on the
//           stockOwned___eXJed cell in Torn's DOM (ul.children[2]) to open
//           the buy/sell panel inline, rather than navigating to a URL.
// v1.5.6 — Fix: openStockPanel rewrote to use correct Torn DOM structure
//           (.tt-acronym span → closest ul → children[2].click()).
// v1.5.5 — Fix: stock IDs hardcoded in STOCK_DATA (confirmed TCP=13, TCT=9
//           from URL patterns). Cards use stockId directly, not API-derived map.
// v1.5.4 — Fix: tickerToId promoted to module-level variable to avoid
//           parameter threading issues across render functions.
// v1.5.3 — Feature: recommendation cards are now clickable — opens the Torn
//           stock buy panel for that stock via DOM click simulation.
// v1.5.2 — Fix: holdings sorted alphabetically; passive stocks shown after
//           a dimmed divider, also sorted alphabetically.
// v1.5.1 — Fix: swap engine rebuilt. Only shows positive net-gain swaps.
//           Passive stocks only appear if manual value set. Best 2 targets
//           per sell action. Combined-sell detection (two stocks together
//           unlock a target neither could fund alone).
// v1.5.0 — Feature: swap advisor. Analyses held blocks and suggests sells
//           to fund better blocks. Shows net daily gain, transition cost
//           (one missed payout cycle), and days to payback. Both full-sell
//           and sell-down-to-lower-block options shown where applicable.
// 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';
  // Module-level ticker→stockId map, populated after first API call
  let tickerToId = {};

  // ─── 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', stockId: 7, 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', stockId: 2, 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', stockId: 21, 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', stockId: 10, 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', stockId: 20, 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', stockId: 17, name: 'Legal Authorities Group',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: "Lawyer's Business Card", payoutItemId: 368, 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', stockId: 27, 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', stockId: 12, 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', stockId: 22, name: 'PointLess',
      // Points valued at live points market price × 100 points per block per 7 days
      payoutType: 'cash', payoutInterval: 7,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '100 points per block, every 7 days (valued at live points market price)',
      increments: [
        { incr: 1, threshold: 10000000  },
        { incr: 2, threshold: 30000000  },
        { incr: 3, threshold: 70000000  },
        { incr: 4, threshold: 150000000 },
        { incr: 5, threshold: 310000000 },
      ],
    },
    {
      ticker: 'EVL', stockId: 26, 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', stockId: 23, 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', stockId: 18, 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', stockId: 16, 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', stockId: 9, 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', stockId: 5, 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', stockId: 14, 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', stockId: 24, name: 'Alcoholics Synonymous',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 2, payoutItemName: 'Six-Pack of Alcohol', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '2× Six Pack of Alcohol per block, every 7 days',
      // Confirmed: B1=3,000,000 shares, 2x Six 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: 'TSB', stockId: 1, 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', stockId: 34, 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', stockId: 6, 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', stockId: 8, 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', stockId: 35, 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', stockId: 13, 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', stockId: 4, 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', stockId: 19, 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', stockId: 25, 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', stockId: 15, 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', stockId: 11, 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', stockId: 3, name: 'Syscore MFG',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Advanced firewall (passive)',
      increments: [ { incr: 1, threshold: 3000000 } ],
    },
    {
      ticker: 'ELT', stockId: 28, 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', stockId: 29, 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', stockId: 31, 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', stockId: 32, 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', stockId: 33, name: 'Yazoo',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Free banner advertising (passive)',
      increments: [ { incr: 1, threshold: 1000000 } ],
    },
    {
      ticker: 'IST', stockId: 30, 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: 10px 14px;
                  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: 7px 14px; 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: 10px 14px; display: flex; gap: 32px; 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: 16px 14px; }
    #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: 8px; padding-top: 18px; border-top: 1px solid #1a2a4a; }
    .tsa-section:first-child { padding-top: 0; border-top: none; }
    .tsa-section-title { color: #ff7700; font-size: 12px; font-weight: bold;
                         text-transform: uppercase; letter-spacing: 1px;
                         border-bottom: 1px solid #333; padding-bottom: 6px; margin-bottom: 10px;
                         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: 12px; margin-bottom: 10px; }
    .tsa-card { background: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px;
                padding: 12px 14px; position: relative; }
    .tsa-card[title]:hover { border-color: #ff7700; background: #1e1e38; }
    .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: 8px; }
    .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: 7px; }
    .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: 12px 0 6px; }
    .tsa-hold-sublabel:first-child { margin-top: 0; }
    /* Grid: ticker | name | badge | daily value | held · cycle */
    .tsa-hold-row { display: grid;
                    grid-template-columns: 44px minmax(160px, 1fr) 80px 100px 130px;
                    align-items: center; gap: 0 10px;
                    padding: 7px 0; border-bottom: 1px solid #0d1525;
                    font-size: 11px; }
    .tsa-hold-row:last-child { border-bottom: none; }
    .tsa-hold-name { color: #ccc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .tsa-hold-val  { color: #44ee88; text-align: right; }
    .tsa-hold-partial-txt { color: #ffaa00; font-size: 10px; }
    /* Partial rows span differently */
    .tsa-hold-partial-row { display: grid;
                            grid-template-columns: 44px minmax(160px,1fr) 44px auto;
                            align-items: center; gap: 0 10px;
                            padding: 5px 0; border-bottom: 1px solid #0d1525;
                            font-size: 11px; }
    .tsa-hold-partial-row:last-child { border-bottom: none; }

    /* 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: 7px 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: 7px 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; }

    /* Swap table */
    table.tsa-swap-table { width: 100%; border-collapse: collapse; font-size: 11px; }
    table.tsa-swap-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-swap-table td { padding: 5px 10px; border-bottom: 1px solid #0d1525; color: #ccc;
                              white-space: nowrap; vertical-align: middle; font-size: 11px; }
    table.tsa-swap-table tbody tr:nth-child(even) td { background: #111827; }
    table.tsa-swap-table tbody tr:nth-child(odd)  td { background: #0f1520; }
    table.tsa-swap-table tr:hover td { background: #1e1e36 !important; }
    .tsa-swap-gain-pos { color: #44ee88; font-weight: bold; }
    .tsa-swap-gain-neg { color: #ff4444; }
    .tsa-swap-arrow { color: #ff7700; font-size: 12px; margin: 0 4px; }
    .tsa-swap-note  { font-size: 10px; color: #555; margin-top: 14px; }

    #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 getSwapNoSell    = () => load('swap_no_sell', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
  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/v2/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;
    }

    // Populate module-level tickerToId map (inverted idToTicker) for clickable links
    for (const [id, tkr] of Object.entries(idToTicker)) {
      tickerToId[tkr] = id;
    }

    // ── 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 = {};

    // Fetch item prices and points market price in parallel
    const [, pointsPrice] = await Promise.all([
      Promise.all(uniqueIds.map(async id => { itemPrices[id] = await fetchItemPrice(apiKey, id); })),
      // Points market — cheapest listed price per point
      // Uses v1 API (market/?selections=pointsmarket) — response is object keyed by listing ID
      // each with a .cost field. Matches approach used in Torn Faction Vault & Armory Manager.
      (async () => {
        try {
          const resp = await fetch(
            `https://api.torn.com/market/?selections=pointsmarket&key=${apiKey}&comment=${SCRIPT}`
          );
          const data = await resp.json();
          if (data.error) return 0;
          const listings = data.pointsmarket || {};
          let cheapest = 0;
          for (const id of Object.keys(listings)) {
            const cost = listings[id].cost;
            if (cost && cost > 1000 && (!cheapest || cost < cheapest)) cheapest = cost;
          }
          return cheapest;
        } catch { return 0; }
      })(),
    ]);

    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 if (ticker === 'PTS' && pointsPrice > 0) {
        // PTS pays 100 points per block per 7 days — value at live points market price
        const pointsValue = pointsPrice * 100;
        incrValue = getPayoutOverride(ticker, pointsValue);
      } 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 };
  }

  // ─── Swap advisor engine ─────────────────────────────────────────────────────
  //
  // SWAP LOGIC:
  //   For each held block (or group of blocks for a stock), consider:
  //     A) Full sell: sell ALL shares in that stock
  //     B) Sell-down: sell shares above the previous block threshold (drop one block)
  //
  //   For each sell candidate, find all scoreable target blocks the freed cash
  //   could fund (costToComplete <= cashReleased).
  //
  //   Score each swap:
  //     cashReleased   = sharesToSell × price
  //     dailyLost      = sum of daily values of blocks being abandoned
  //     transitionCost = dailyLost × payoutInterval  (one missed payout cycle)
  //     dailyGained    = target block's dailyValue
  //     netDailyGain   = dailyGained − dailyLost
  //     paybackDays    = transitionCost / netDailyGain  (if positive)
  //
  //   Only viable swaps shown: cashReleased >= costToComplete of target.
  //   All swaps shown regardless of payback — user decides.

  function buildSwaps(rows) {
    // ── Build sell candidates ─────────────────────────────────────────────────
    // A stock is a valid sell candidate if:
    //   - User holds at least one block (status === 'held')
    //   - Not ignored
    //   - Has a measurable daily value (dailyValue > 0)
    //     → passive stocks with no manual value set are excluded automatically

    const noSellTickers = getSwapNoSell();
    const heldByTicker = {};
    for (const r of rows.filter(r => r.status === 'held' && !r.ignored && r.dailyValue > 0 && !noSellTickers.includes(r.ticker))) {
      if (!heldByTicker[r.ticker]) heldByTicker[r.ticker] = [];
      heldByTicker[r.ticker].push(r);
    }

    // ── Build target blocks ──────────────────────────────────────────────────
    // Targets: scoreable, positive daily value, has a cost, not already held
    const targets = rows.filter(r =>
      r.scoreable && r.score > 0 && r.costToComplete > 0 && r.dailyValue > 0
    );

    // Helper: score a single sell→buy swap, return null if net gain <= 0
    function makeSwap(sellTicker, sellName, sellType, sellDesc, cashReleased,
                      dailyLost, interval, target, extraSells) {
      if (target.ticker === sellTicker) return null;
      if (target.costToComplete > cashReleased) return null;
      const netDailyGain = target.dailyValue - dailyLost;
      if (netDailyGain <= 0) return null; // only positive-gain swaps
      const transCost  = dailyLost * (interval || 1);
      const paybackDays = Math.ceil(transCost / netDailyGain);
      return {
        sellTicker, sellName, sellType, sellDesc, cashReleased,
        dailyLost, transCost,
        leftoverCash: cashReleased - target.costToComplete,
        target, netDailyGain, paybackDays,
        extraSells: extraSells || [],
        combined: false,
      };
    }

    const swaps = [];
    const sellProfiles = []; // for combined-sell detection later

    for (const [sellTicker, heldBlocks] of Object.entries(heldByTicker)) {
      const sorted    = [...heldBlocks].sort((a, b) => b.incr - a.incr);
      const topBlock  = sorted[0];
      const price     = topBlock.price;
      if (!price || price <= 0) continue;

      const totalShares   = topBlock.sharesHeld;
      const cashFullSell  = totalShares * price;
      const dailyLostFull = heldBlocks.reduce((s, r) => s + r.dailyValue, 0);
      const maxInterval   = Math.max(...heldBlocks.map(r => r.payoutInterval || 0));

      // ── Option A: Full sell ──────────────────────────────────────────────
      // Find the top 2 targets by netDailyGain for this sell
      const fullTargets = targets
        .map(t => makeSwap(sellTicker, topBlock.name, 'full',
                           `Sell all ${fmtShares(totalShares)} shares`,
                           cashFullSell, dailyLostFull, maxInterval, t, []))
        .filter(Boolean)
        .sort((a, b) => b.netDailyGain - a.netDailyGain)
        .slice(0, 2);
      swaps.push(...fullTargets);

      // Store profile for combined-sell detection
      sellProfiles.push({
        ticker: sellTicker, name: topBlock.name,
        cash: cashFullSell, dailyLost: dailyLostFull, interval: maxInterval,
      });

      // ── Option B: Sell-down (drop top block only) ────────────────────────
      if (topBlock.prevThresh > 0) {
        const sellShares    = totalShares - topBlock.prevThresh;
        const cashSellDown  = sellShares * price;
        const dailyLostDown = topBlock.dailyValue;
        if (cashSellDown > 0) {
          const downTargets = targets
            .map(t => makeSwap(sellTicker, topBlock.name, 'down',
                               `Drop to Block ${topBlock.incr - 1} (sell ${fmtShares(sellShares)} shares)`,
                               cashSellDown, dailyLostDown, topBlock.payoutInterval || 1, t, []))
            .filter(Boolean)
            .sort((a, b) => b.netDailyGain - a.netDailyGain)
            .slice(0, 2);
          swaps.push(...downTargets);
        }
      }
    }

    // ── Option C: Combined sell (two stocks together unlock a target) ────────
    // For each target, check if any two full-sell profiles together can fund it
    // but neither alone can. Cap at reasonable combinations to avoid O(n³).
    const profiles = sellProfiles;
    for (let i = 0; i < Math.min(profiles.length, 8); i++) {
      for (let j = i + 1; j < Math.min(profiles.length, 8); j++) {
        const pA = profiles[i];
        const pB = profiles[j];
        const combinedCash    = pA.cash + pB.cash;
        const combinedLost    = pA.dailyLost + pB.dailyLost;
        const combinedInterval= Math.max(pA.interval, pB.interval);

        // Find targets that need more than either alone can fund, but combined can
        const combinedTargets = targets
          .filter(t =>
            t.ticker !== pA.ticker &&
            t.ticker !== pB.ticker &&
            t.costToComplete > pA.cash &&          // can't afford with A alone
            t.costToComplete > pB.cash &&          // can't afford with B alone
            t.costToComplete <= combinedCash       // can afford together
          )
          .map(t => {
            const netDailyGain = t.dailyValue - combinedLost;
            if (netDailyGain <= 0) return null;
            const transCost   = combinedLost * combinedInterval;
            const paybackDays = Math.ceil(transCost / netDailyGain);
            return {
              sellTicker:   `${pA.ticker}+${pB.ticker}`,
              sellName:     `${pA.name} + ${pB.name}`,
              sellType:     'combined',
              sellDesc:     `Sell all ${pA.ticker} + all ${pB.ticker}`,
              cashReleased: combinedCash,
              dailyLost:    combinedLost,
              transCost,
              leftoverCash: combinedCash - t.costToComplete,
              target:       t,
              netDailyGain,
              paybackDays,
              extraSells:   [pA.ticker, pB.ticker],
              combined:     true,
            };
          })
          .filter(Boolean)
          .sort((a, b) => b.netDailyGain - a.netDailyGain)
          .slice(0, 1); // best combined target only

        swaps.push(...combinedTargets);
      }
    }

    // Sort all swaps by payback days ascending (all are positive net gain)
    swaps.sort((a, b) => {
      const aPb = isFinite(a.paybackDays) ? a.paybackDays : 999999;
      const bPb = isFinite(b.paybackDays) ? b.paybackDays : 999999;
      return aPb - bPb;
    });

    return swaps;
  }

  // ─── 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.7.0</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 id="tsa-strip-nosell-wrap" style="display:none">No sell: <span id="tsa-strip-nosell" style="color:#ff7700"></span></span>
        <span class="tsa-sep" id="tsa-strip-nosell-sep" style="display:none">|</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">Never sell these stocks (comma-separated tickers — excluded from Swap Advisor sell candidates)</span>
        <div class="tsa-cfg-row">
          <label>Never sell</label>
          <input class="tsa-cfg-input" id="tsa-cfg-swap-no-sell" type="text" placeholder="e.g. TMI, FHG" 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-swap-title">Swap Advisor — Sell to Buy Better</div>
            <div id="tsa-swap-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-swap-no-sell').value  = load('swap_no_sell', '');
    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 noSell = getSwapNoSell();
    const noSellWrap = root.querySelector('#tsa-strip-nosell-wrap');
    const noSellSep  = root.querySelector('#tsa-strip-nosell-sep');
    if (noSellWrap) {
      noSellWrap.style.display = noSell.length ? '' : 'none';
      if (noSellSep) noSellSep.style.display = noSell.length ? '' : 'none';
      const ns = root.querySelector('#tsa-strip-nosell');
      if (ns) ns.textContent = noSell.join(', ');
    }
    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>`;
      }

      // Find stockId from STOCK_DATA directly — always reliable
      const stockDef = STOCK_DATA.find(s => s.ticker === r.ticker);
      const stockId  = stockDef ? stockDef.stockId : null;
      const stockUrl = stockId
        ? `https://www.torn.com/page.php?sid=stocks&stockID=${stockId}&tab=owned`
        : null;

      function openStockPanel() {
        // Torn renders stocks as <ul class="stock___ElSDB"> containing 4 <li> children:
        //   0: stockName___tLa3y  (has .tt-acronym span with "(TCK)" text)
        //   1: stockPrice___WCQuw
        //   2: stockOwned___eXJed  ← clicking this opens the buy/sell panel
        //   3: stockDividend___U_p9H
        const acronymEls = document.querySelectorAll('.tt-acronym');
        for (const span of acronymEls) {
          if (span.textContent.trim() === `(${r.ticker})`) {
            const ul = span.closest('ul');
            if (ul && ul.children[2]) {
              ul.children[2].click();
              ul.scrollIntoView({ behavior: 'smooth', block: 'center' });
              return;
            }
          }
        }
        // Fallback: navigate to URL
        if (stockUrl) window.location.href = stockUrl;
      }

      const card = document.createElement('div');
      card.className = 'tsa-card';
      if (stockUrl || true) {
        card.style.cursor = 'pointer';
        card.title = `Open ${r.ticker} buy panel`;
        card.addEventListener('click', openStockPanel);
      }
      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}
          ${stockUrl ? '<span style="color:#444;font-size:9px;margin-left:6px">↗</span>' : ''}
        </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
      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;
      }

      // Sort: active (dailyValue > 0) alphabetically first, then passives alphabetically
      const entries = Object.values(byTicker).sort((a, b) => {
        const aPassive = a.r.payoutType === 'passive';
        const bPassive = b.r.payoutType === 'passive';
        if (aPassive !== bPassive) return aPassive ? 1 : -1;
        return a.r.ticker.localeCompare(b.r.ticker);
      });

      let lastWasActive = true;
      for (const { r, count, totalDaily } of entries) {
        const isPassive = r.payoutType === 'passive';

        // Insert a divider label before the first passive entry
        if (isPassive && lastWasActive) {
          const divider = document.createElement('div');
          divider.className = 'tsa-hold-sublabel';
          divider.style.marginTop = '8px';
          divider.style.opacity = '0.5';
          divider.textContent = 'Passive benefits (no active payout)';
          content.appendChild(divider);
          lastWasActive = false;
        }

        const iLabel = r.payoutInterval > 0 ? `every ${r.payoutInterval}d` : 'passive';
        const row = document.createElement('div');
        row.className = 'tsa-hold-row';
        if (isPassive) row.style.opacity = '0.55';

        row.innerHTML = `
          <span class="tsa-ticker">${r.ticker}</span>
          <span class="tsa-hold-name">${r.name}</span>
          <span class="tsa-badge ${isPassive ? 'tsa-badge-passive' : 'tsa-badge-ok'}" style="justify-self:end">${count} block${count > 1 ? 's' : ''} ✓</span>
          <span class="tsa-hold-val">${totalDaily > 0 ? fmtMoney(totalDaily) + '/day' : '—'}</span>
          <span style="color:#444;font-size:10px;white-space:nowrap">${fmtShares(r.sharesHeld)} &nbsp;·&nbsp; ${iLabel}</span>
        `;
        content.appendChild(row);
      }
    }

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

      // Sort partials alphabetically too
      const sortedPartial = [...partial].sort((a, b) => a.ticker.localeCompare(b.ticker));
      for (const r of sortedPartial) {
        const pct = Math.round(((r.sharesHeld - r.prevThresh) / r.incrShares) * 100);
        const row = document.createElement('div');
        row.className = 'tsa-hold-partial-row';
        row.innerHTML = `
          <span class="tsa-ticker">${r.ticker}</span>
          <span class="tsa-hold-name">${r.name} <span style="color:#444;font-size:10px">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: swap advisor ───────────────────────────────────────────────────

  function renderSwaps(root, rows) {
    const content = root.querySelector('#tsa-swap-content');
    if (!content) return;
    content.innerHTML = '';

    const swaps = buildSwaps(rows);

    if (!swaps.length) {
      content.innerHTML = '<div style="color:#555;font-size:12px;padding:6px 0">No positive-gain swaps found — holdings appear well-optimised, or no affordable target beats what you give up.</div>';
      return;
    }

    // Group swaps by sell action for clearer presentation
    const groups = [];
    const seenSell = new Map();
    for (const s of swaps) {
      const key = `${s.sellTicker}|${s.sellType}`;
      if (!seenSell.has(key)) {
        seenSell.set(key, []);
        groups.push({ key, swaps: seenSell.get(key) });
      }
      seenSell.get(key).push(s);
    }

    for (const group of groups) {
      const first = group.swaps[0];

      // Group header — the sell action
      const header = document.createElement('div');
      // Add divider above each group except the first
      if (groups.indexOf(group) > 0) {
        const divider = document.createElement('div');
        divider.style.cssText = 'border-top:1px solid #1a2a4a;margin:20px 0 0;';
        content.appendChild(divider);
      }
      header.style.cssText = 'margin:14px 0 8px;font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.5px;';
      const sellTypeBadge = first.sellType === 'combined'
        ? '<span class="tsa-badge" style="background:#1a0a30;color:#cc88ff;font-size:9px">Combined sell</span>'
        : first.sellType === 'full'
          ? '<span class="tsa-badge" style="background:#2a0a00;color:#ff8866;font-size:9px">Full sell</span>'
          : '<span class="tsa-badge" style="background:#2a1a00;color:#ffaa44;font-size:9px">Sell down</span>';
      const sellDesc = first.combined
        ? `Sell all <span class="tsa-ticker">${first.extraSells[0]}</span> + all <span class="tsa-ticker">${first.extraSells[1]}</span> <span style="color:#444">(${fmtMoney(first.cashReleased)} freed)</span>`
        : first.sellType === 'full'
          ? `Sell all <span class="tsa-ticker">${first.sellTicker}</span> <span style="color:#444">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · lose ${fmtMoney(first.dailyLost)}/day)</span>`
          : `Drop <span class="tsa-ticker">${first.sellTicker}</span> down one block <span style="color:#444">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · lose ${fmtMoney(first.dailyLost)}/day)</span>`;
      header.innerHTML = `${sellTypeBadge} &nbsp;${sellDesc}`;
      content.appendChild(header);

      // Buy options for this sell — labelled as alternatives
      group.swaps.forEach((s, idx) => {
        const card = document.createElement('div');
        const isAlt = idx > 0;
        card.style.cssText = `background:${isAlt ? '#0d1420' : '#111827'};border:1px solid ${isAlt ? '#151f30' : '#1a2a4a'};border-radius:5px;padding:10px 14px;margin-bottom:8px;margin-left:${isAlt ? '16px' : '0'};`;

        const paybackColour = s.paybackDays <= 30 ? '#44ee88' : s.paybackDays <= 90 ? '#ffaa00' : '#888';
        const optionLabel = group.swaps.length > 1
          ? `<span style="color:#444;font-size:10px;margin-right:6px">${isAlt ? '↳ or' : 'Buy'}</span>`
          : `<span style="color:#444;font-size:10px;margin-right:6px">Buy</span>`;

        card.innerHTML = `
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            ${optionLabel}
            <span style="font-size:12px;color:#ccc">
              <span class="tsa-ticker">${s.target.ticker}</span>
              <span style="color:#888;margin-left:4px">Block ${s.target.incr}</span>
              <span style="color:#555;font-size:10px;margin-left:4px">(${s.target.name})</span>
            </span>
            <span style="color:#555;font-size:11px">costs ${fmtMoney(s.target.costToComplete)}</span>
            ${s.leftoverCash > 1000 ? `<span style="color:#444;font-size:10px">· ${fmtMoney(s.leftoverCash)} leftover</span>` : ''}
            <span style="margin-left:auto;font-size:12px;color:#44ee88;font-weight:bold">+${fmtMoney(s.netDailyGain)}/day</span>
            <span style="font-size:12px;font-weight:bold;color:${paybackColour}">Payback: ${s.paybackDays}d</span>
          </div>
          <div style="margin-top:5px;font-size:10px;color:#444">
            You start earning <span style="color:#44ee88">+${fmtMoney(s.target.dailyValue)}/day</span> from ${s.target.ticker} — net gain after losing ${fmtMoney(s.dailyLost)}/day from the sell
          </div>
        `;
        content.appendChild(card);
      });
    }

    const note = document.createElement('div');
    note.style.marginTop = '12px';
    note.className = 'tsa-swap-note';
    note.innerHTML = `
      Each row = one sell action → one buy. Sorted by payback days (fastest first).
      Payback = days until the daily gain recovers the one payout cycle you miss during the swap.
      <span style="color:#44ee88">≤30d</span> = strong &nbsp;·&nbsp;
      <span style="color:#ffaa00">≤90d</span> = reasonable &nbsp;·&nbsp;
      <span style="color:#888">&gt;90d</span> = long-term only &nbsp;·&nbsp;
      <span style="color:#cc88ff">Combined sell</span> = sell two stocks together to fund a target neither could afford alone.
    `;
    content.appendChild(note);
  }

  // ─── 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);
      renderSwaps(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('swap_no_sell',  root.querySelector('#tsa-cfg-swap-no-sell').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-swap-title'),   root.querySelector('#tsa-swap-content'),   'collapse_swaps',     'collapsed');
    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); });

})();