Greasy Fork is available in English.
Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table.
当前为
// ==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 & 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> ${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> · Cost: <strong>${fmtMoney(r.costToComplete)}</strong></div>
<div class="tsa-card-roi">Daily ROI: ${fmtROI(r.dailyValue, r.incrCost)} · 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)} · ${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)} · need ${fmtShares(r.sharesNeeded)} more · ${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} ${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 ·
<span style="color:#ffaa00">≤90d</span> = reasonable ·
<span style="color:#888">>90d</span> = long-term only ·
<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); });
})();