// ==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 // @downloadURL none // ==/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 = `
Torn Stock Advisor v1.7.0 Not loaded
API Key: ✗ Not set | Ignored: None | Excl: None | Budget: Off | Refresh: 5 min
API Key — requires Stocks access level
Stored locally only — never sent except to api.torn.com.
Ignore stocks (comma-separated tickers — removed from scoring and table)
Never sell these stocks (comma-separated tickers — excluded from Swap Advisor sell candidates)
Exclude payout types from scoring (still visible in table)
Budget filter
Leave blank to disable budget filter
e.g. 110 = up to 10% above budget
Display
Per-stock payout value overrides & block thresholds Item stocks use live market price automatically; enter $ override to fix the value. Cash stocks use defaults from community data.
Active Blocks
Partial
Daily Payout
Best Daily ROI
Stocks Tracked${STOCK_DATA.length}
Loading stock data…
`; 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 = ` ${ticker} ${name} ${iLabel} `; 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 = ` ${payoutDesc} `; 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 = ` default: ${fmtShares(threshold)} shares total `; 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 = '
No scoreable blocks — check config or API key.
'; 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 = `
▶ Already hold ${fmtShares(r.sharesHeld - r.prevThresh)} / ${fmtShares(r.incrShares)} shares (${pct}% there)
`; } // 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