Buy/sell recommendations for Torn City stock exchange — benefit ROI, price momentum, and portfolio P/L
// ==UserScript==
// @name Torn Stock Advisor
// @namespace https://torn.com/stock-advisor
// @version 1.17.2
// @description Buy/sell recommendations for Torn City stock exchange — benefit ROI, price momentum, and portfolio P/L
// @author Nicram
// @match https://www.torn.com/page.php?sid=stocks*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.xmlHttpRequest
// @connect api.torn.com
// @connect tornsy.com
// @license MIT
// @run-at document-idle
//
// ── API USAGE ───────────────────────────────────────────
// Access: Limited (read-only; no trading, no writes)
// Key storage: Locally in your userscript manager only
// Key shared with: Nobody
// API data used: Stocks, money, bank rates
// External service: Tornsy is used for public stock price history;
// your Torn API key is never sent to Tornsy.
// ==/UserScript==
(function () {
'use strict';
// ── GM API COMPAT (Greasemonkey 4+ uses async GM.* instead of sync GM_*) ──
const _gmGet = (typeof GM !== 'undefined' && GM.getValue)
? GM.getValue.bind(GM)
: (typeof GM_getValue === 'function')
? (k, d) => Promise.resolve(GM_getValue(k, d))
: (k, d) => Promise.resolve(d);
const _gmSet = (typeof GM !== 'undefined' && GM.setValue)
? GM.setValue.bind(GM)
: (typeof GM_setValue === 'function')
? (k, v) => Promise.resolve(GM_setValue(k, v))
: () => Promise.resolve();
const _gmXhr = (typeof GM !== 'undefined' && GM.xmlHttpRequest)
? GM.xmlHttpRequest.bind(GM)
: (typeof GM_xmlhttpRequest === 'function')
? GM_xmlhttpRequest
: null;
// ── CONFIG ──────────────────────────────────────────────
// Single source of truth for the displayed version (about dialogs).
// Keep in sync with the @version metadata line above — metadata can't
// read JS, so those are the only two places a release touches.
const TSA_VERSION = '1.17.1';
const CACHE_TTL = 60_000; // 60s between API refreshes
const SELL_FEE = 0.001; // 0.1% sell fee
// Minimum progress (% of BB requirement) for a holding to count as a real
// "partial BB" attempt rather than a token / BUY-LOW leftover. Token holdings
// below this threshold fall through to normal SELL/no-signal logic instead
// of producing a noisy WAIT card. Tier S/A perks get a lower threshold so
// we don't lose visibility into early-stage pursuits of top-tier passive
// benefits (TCB, WSU, WLT — they're rarer and worth tracking sooner).
const PARTIAL_BB_MIN_PCT = 5;
const PARTIAL_BB_MIN_PCT_TIER_SA = 1;
// Known benefit block data (requirement = shares needed for 1 BB)
// type: 'passive' or 'active', frequency in days, estimated annual value in $
const BENEFIT_DB = {
1: { name: 'TCSE', acronym: 'TCSE' },
2: { name: 'Torn City Investments', acronym: 'TCI' },
3: { name: 'Syscore MFG', acronym: 'SYS' },
4: { name: 'Legal Authorities', acronym: 'LAG' },
5: { name: 'Insured On Us', acronym: 'IOU' },
6: { name: 'Grain', acronym: 'GRN' },
7: { name: 'Torn City Health Service', acronym: 'TCHS' },
8: { name: 'Yazoo', acronym: 'YAZ' },
9: { name: 'Torn City Times', acronym: 'TCT' },
10: { name: 'Crude & Co', acronym: 'CNC' },
11: { name: 'Messaging Inc.', acronym: 'MSG' },
12: { name: 'TC Music Industries', acronym: 'TMI' },
13: { name: 'TC Media Productions', acronym: 'TCP' },
14: { name: 'I Industries Ltd.', acronym: 'IIL' },
15: { name: 'Feathery Hotels Group', acronym: 'FHG' },
16: { name: 'Symbiotic Ltd.', acronym: 'SYM' },
17: { name: 'Lucky Shots Casino', acronym: 'LSC' },
18: { name: 'Performance Ribaldry', acronym: 'PRN' },
19: { name: 'Eaglewood Mercenary', acronym: 'EWM' },
20: { name: 'Torn City Banking', acronym: 'TCB' },
21: { name: 'Munster Beverage Corp.', acronym: 'MUN' },
22: { name: 'West Side University', acronym: 'WSU' },
23: { name: 'International School TC', acronym: 'IST' },
24: { name: 'Big Al\'s Gun Shop', acronym: 'BAG' },
25: { name: 'Evil Ducks Candy Corp', acronym: 'EVL' },
26: { name: 'Mc Smoogle Corp', acronym: 'MCS' },
27: { name: 'Wind Lines Travel', acronym: 'WLT' },
28: { name: 'TC National Gas', acronym: 'TNG' },
29: { name: 'TC National Electric', acronym: 'TNE' },
30: { name: 'Home Retail Group', acronym: 'HRG' },
31: { name: 'Tell Group Plc.', acronym: 'TGP' },
32: { name: 'Alcoholics Synonymous', acronym: 'ASS' },
// 33, 34: unknown — Torn likely has stocks here we don't have data for yet
35: { name: 'PointLess', acronym: 'PTS' },
};
// ── BENEFIT VALUES (estimated annual payout from community data) ──
const BENEFIT_VALUES = {
1: { annual: 0, note: 'No direct benefit' },
2: { annual: 0, note: 'No direct benefit' },
3: { annual: 0, note: 'Boosts company productivity (always on)' },
4: { annual: 0, note: 'Legal perks (always on)' },
5: { annual: 0, note: 'Insurance perks (always on)' },
6: { annual: 0, note: 'Food benefits (always on)' },
7: { annual: 0, note: 'Faster medical recovery (always on)' },
8: { annual: 0, note: 'Better drug effects (always on)' },
9: { annual: 11_774_193, note: '$1M every 31 days (holding 100k shares)' },
10: { annual: 0, note: 'Energy refill perks (always on)' },
11: { annual: 0, note: 'Messaging perks (always on)' },
12: { annual: 0, note: 'Music benefits (always on)' },
13: { annual: 0, note: 'Media perks (always on)' },
14: { annual: 0, note: 'Industry perks (always on)' },
15: { annual: 52_000_000, note: 'Free hotel coupons every week (~$1M/wk)' },
16: { annual: 100_000_000, note: 'Free drug packs every week (~$2M/wk)' },
17: { annual: 0, note: '1x Lottery Voucher every week' },
18: { annual: 0, note: 'Adult entertainment perks (always on)' },
19: { annual: 0, note: 'Mercenary bonuses (always on)' },
20: { annual: 0, note: '+10% bank interest (always on)', tier: 'S' },
21: { annual: 70_000_000, note: 'Free cans every week (~$1.3M/wk)' },
22: { annual: 0, note: '10% faster education (always on)', tier: 'S' },
23: { annual: 0, note: 'Education perks (always on)' },
24: { annual: 0, note: 'Gun shop perks (always on)' },
25: { annual: 0, note: 'Candy perks (always on)' },
26: { annual: 0, note: 'Food perks (always on)' },
27: { annual: 0, note: 'Free flights (always on)', tier: 'A' },
28: { annual: 0, note: 'Gas perks (always on)' },
29: { annual: 0, note: 'Electric perks (always on)' },
30: { annual: 200_000_000, note: 'Random free property (~$3.8M/wk)' },
31: { annual: 0, note: 'Phone perks (always on)' },
// ASS: 1× Six-Pack of Alcohol per week, market value ~$868,667 per pack
// (user-provided community value). annual = 868_667 × 52 weeks ≈ $45.17M.
32: { annual: 45_170_684, note: '1x Six-Pack of Alcohol every week (~$868K/wk)' },
35: { annual: 500_000_000, note: '100 free points every week (~$9.6M/wk)' },
};
// ── SCORING WEIGHTS ─────────────────────────────────────
const FORECAST_SCORE = {
'Very Good': 2, 'Good': 1, 'Average': 0, 'Poor': -1, 'Very Poor': -2
};
const DEMAND_SCORE = {
'Very High': 2, 'High': 1, 'Average': 0, 'Low': -1, 'Very Low': -2
};
// ── INVESTOR STYLE PROFILES ─────────────────────────────
const STYLE_PROFILES = {
daytrader: {
label: 'Day Trader',
description: 'Catch momentum swings, take profits quickly, cut losses fast',
sellProfitThreshold: 0.5,
sellStagnantEnabled: true,
sellStagnantThreshold: 0.2,
sellLossThreshold: -1.5,
checkLossThreshold: -1,
keepFloor: -1.5,
buyDipEnabled: true,
buyDipPriorityBoost: 10,
buyDipVolMult: 0.75,
sellNowEnabled: true,
sellNowPriorityBoost: 10,
techSellEnabled: true,
techSellPriorityBoost: 10,
techSellPnlOverride: null,
sellSoonRangeEnabled: true,
sellSoonRangePriorityBoost: 10,
sellSoonRsiEnabled: true,
sellSoonRsiThreshold: 65,
sellSoonRsiPriorityBoost: 10,
sellSoonRsiMinPnl: 0,
rsiPeriod: 14,
watchEnabled: true,
watchPriorityBoost: 10,
buyTierSPriorityBoost: -10,
buyTierAPriorityBoost: -10,
buyHighRoiPriorityBoost: -10,
maybeBuyEnabled: false,
maybeBuyPriorityBoost: 0,
rebalancePriorityBoost: -10,
},
balanced: {
label: 'Active Investor',
description: 'All signals balanced — benefits, momentum, and P/L together',
sellProfitThreshold: 2,
sellStagnantEnabled: true,
sellStagnantThreshold: 0.5,
sellLossThreshold: -5,
checkLossThreshold: -5,
keepFloor: -5,
buyDipEnabled: true,
buyDipPriorityBoost: 0,
buyDipVolMult: 1.25,
sellNowEnabled: true,
sellNowPriorityBoost: 0,
techSellEnabled: true,
techSellPriorityBoost: 0,
techSellPnlOverride: null,
sellSoonRangeEnabled: true,
sellSoonRangePriorityBoost: 0,
sellSoonRsiEnabled: true,
sellSoonRsiThreshold: 65,
sellSoonRsiPriorityBoost: 0,
sellSoonRsiMinPnl: 1,
rsiPeriod: 72,
watchEnabled: true,
watchPriorityBoost: 0,
buyTierSPriorityBoost: 0,
buyTierAPriorityBoost: 0,
buyHighRoiPriorityBoost: 0,
maybeBuyEnabled: true,
maybeBuyPriorityBoost: 0,
rebalancePriorityBoost: 0,
},
longterm: {
label: 'Long-Term Investor',
description: 'Focus on benefit ROI, ignore short-term noise, hold through dips',
sellProfitThreshold: 3,
sellStagnantEnabled: false,
sellStagnantThreshold: 0,
sellLossThreshold: -8,
checkLossThreshold: -8,
keepFloor: -10,
buyDipEnabled: false,
buyDipPriorityBoost: 0,
buyDipVolMult: 1.75,
sellNowEnabled: false,
sellNowPriorityBoost: 0,
techSellEnabled: true,
techSellPriorityBoost: 0,
techSellPnlOverride: -8,
sellSoonRangeEnabled: false,
sellSoonRangePriorityBoost: 0,
sellSoonRsiEnabled: true,
sellSoonRsiThreshold: 80,
sellSoonRsiPriorityBoost: 0,
sellSoonRsiMinPnl: 2,
rsiPeriod: 168,
watchEnabled: false,
watchPriorityBoost: 0,
buyTierSPriorityBoost: 10,
buyTierAPriorityBoost: 10,
buyHighRoiPriorityBoost: 10,
maybeBuyEnabled: true,
maybeBuyPriorityBoost: 10,
rebalancePriorityBoost: 10,
}
};
function getStyleProfile() {
return STYLE_PROFILES[investorStyle] || STYLE_PROFILES.balanced;
}
// ── TORNPDA DETECTION ───────────────────────────────────
const IS_PDA = typeof PDA_httpGet === 'function'
|| (typeof window !== 'undefined' && !!window.flutter_inappwebview);
const PDA_KEY_PLACEHOLDER = '###PDA-APIKEY###';
// ── STATE ───────────────────────────────────────────────
// State vars — loaded asynchronously in init() for Greasemonkey compat
let apiKey = '';
let investorStyle = '';
let lastFetch = 0;
let marketData = null;
let portfolioData = null;
// Locked stocks — user-selected stocks to protect from sell/rebalance signals
let lockedStocks = [];
// Bank APR table (term days → APR %) — auto-fetched from torn/?selections=bank.
// Keys match Torn's five bank terms: 1w, 2w, 1m, 2m, 3m.
let bankAprTable = { 7: 0, 14: 0, 31: 0, 62: 0, 93: 0 };
// User-configured interest bonus, as a PERCENTAGE OF THE BASE RATE — not
// percentage points. Torn's Bank Interest merits (+5% each, 10 max) and the
// TCB benefit block (+10%) both scale the rate rather than adding to it, so
// 10 merits + TCB on a 60% base is 60 × 1.60 = 96%, not 60 + 60 = 120%.
// Neither bonus appears in the API response, hence the manual setting.
let bankBonusPct = 0;
// True when a pre-1.17.1 percentage-POINTS value was found in storage. The
// two scales aren't convertible (the old prompt couldn't tell merits from
// the TCB block), so the value is dropped rather than silently misapplied
// and the Signals footer asks for it to be re-entered.
let bankBonusNeedsReview = false;
// Active bank investment state — users can only hold ONE investment at a time
// in Torn, so when one is active, the bank isn't a viable alternative for
// freeing capital from a benefit-block stock until it matures.
let bankInvestment = { active: false, amount: 0, timeLeft: 0 };
// User cash sources from user/?selections=money. `immediate` is what we use
// for top-up affordability checks (wallet + personal vault only).
// Company funds are deliberately EXCLUDED: they belong to the company and
// pay employee salaries, so they aren't the user's spendable cash.
// Cayman is tracked separately because it has a 7d withdrawal delay.
let userCash = {
wallet: 0, // money_onhand — instant
vault: 0, // vault_amount — instant
cayman: 0, // cayman_bank — 7-day withdrawal delay
immediate: 0 // wallet + vault
};
// Count of buy-worthy benefit blocks suppressed because they cost more than
// the user's detected buying power (set in generateRecommendations, surfaced
// in the Signals footer so suggestions never vanish without explanation).
let buyingPowerHiddenCount = 0;
// Count of benefit blocks with a decent (5–15%/yr) return that the active
// investor profile suppresses because it has MAYBE BUY switched off.
let profileHiddenBuyCount = 0;
// How many stocks had no usable Tornsy candle history this refresh. When
// this is the whole market, every momentum signal (BUY LOW, SELL NOW,
// SELL SOON, WATCH) is silently impossible — the footer says so.
let noTechnicalsCount = 0;
// The stocks that came closest to a BUY LOW without qualifying, each with
// the single gate it failed. Surfaced when no BUY LOW fired, so a genuinely
// flat market is distinguishable from a broken signal.
let buyLowNearMisses = [];
// How many holdings sit at a COMPLETE passive benefit block. Passive perks
// are binary — once the requirement is met, more shares buy nothing — so
// these stocks genuinely have no buy signal left in them. A player who owns
// a block of every stock would otherwise see a Signals tab of nothing but
// KEEP with no explanation; the footer names this.
let maxedPassiveCount = 0;
// Torn applies interest bonuses as a multiplier on the displayed rate, not
// as points added to it: 10 Bank Interest merits (+50%) plus the TCB block
// (+10%) turn a 60% base into 60 × 1.60 = 96%, not 60 + 60 = 120%. Pure and
// exported so the formula stays pinned — the additive version inflated every
// effective APR and wrongly ruled out benefit blocks as "beaten by the bank".
function effectiveBankApr(baseApr, bonusPct) {
if (!(baseApr > 0)) return 0;
const bonus = Number.isFinite(bonusPct) ? bonusPct : 0;
return baseApr * (1 + bonus / 100);
}
// Human-readable term label map used in UI and recommendation text
const BANK_TERM_LABELS = { 7: '1w', 14: '2w', 31: '1m', 62: '2m', 93: '3m' };
// Match a benefit block's payout frequency to the shortest bank term ≥ freqDays.
// Returns { apr, termDays, termLabel } where apr already includes bankBonusPct.
// The bonus MULTIPLIES the base rate (see bankBonusPct) — adding it as
// points overstated every effective APR, and with it the number of benefit
// blocks written off as "the bank beats this".
function getBankAprForFrequency(freqDays) {
const terms = [7, 14, 31, 62, 93];
let termDays = 93;
for (const t of terms) {
if (freqDays <= t) { termDays = t; break; }
}
const baseApr = bankAprTable[termDays] || 0;
return {
apr: effectiveBankApr(baseApr, bankBonusPct),
termDays,
termLabel: BANK_TERM_LABELS[termDays]
};
}
// Local price history — stores snapshots to compute actual price changes
// Format: { stockId: [{ ts: timestamp, price: number }, ...] }
let priceHistory = {};
const HISTORY_MAX_AGE = 3600_000; // keep 1 hour of data
const HISTORY_MAX_ENTRIES = 60; // max snapshots per stock
function recordPrices(stocks) {
const now = Date.now();
for (const [id, stock] of Object.entries(stocks)) {
if (!priceHistory[id]) priceHistory[id] = [];
const arr = priceHistory[id];
// Only record if > 30s since last entry (throttles duplicate snapshots)
const last = arr[arr.length - 1];
if (!last || now - last.ts > 30_000) {
arr.push({ ts: now, price: stock.current_price });
}
// Trim old entries
while (arr.length > 0 && now - arr[0].ts > HISTORY_MAX_AGE) arr.shift();
while (arr.length > HISTORY_MAX_ENTRIES) arr.shift();
}
_gmSet('tornStockAdvisor_priceHistory', JSON.stringify(priceHistory));
}
// Returns { pct, spanMin } — the % change AND the actual window it covers.
// Right after page load the window is only a couple of minutes, so callers
// must not present pct as an hourly rate (and should require a minimum
// span before treating a small dip as a real trend).
function getPriceChange(id) {
const arr = priceHistory[id];
if (!arr || arr.length < 2) return null;
const oldest = arr[0];
const newest = arr[arr.length - 1];
if (!oldest.price || oldest.price === 0) return null;
return {
pct: ((newest.price - oldest.price) / oldest.price) * 100,
spanMin: Math.max(1, Math.round((newest.ts - oldest.ts) / 60_000))
};
}
// ── API HELPERS ─────────────────────────────────────────
function tornFetch(endpoint) {
return new Promise((resolve, reject) => {
_gmXhr({
method: 'GET',
url: `https://api.torn.com/${endpoint}&key=${apiKey}`,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
if (data.error) reject(new Error(`API error ${data.error.code}: ${data.error.error}`));
else resolve(data);
} catch (e) { reject(e); }
},
onerror: (e) => reject(e)
});
});
}
function tornsyFetch(acronym, interval = 'h1') {
return new Promise((resolve, reject) => {
_gmXhr({
method: 'GET',
url: `https://tornsy.com/api/${acronym}?interval=${interval}`,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
resolve(data.data || []);
} catch (e) { resolve([]); }
},
onerror: () => resolve([])
});
});
}
// ── TECHNICAL ANALYSIS ──────────────────────────────────
// Tornsy OHLC: [timestamp, open, high, low, close, total_shares]
function calcSMA(closes, period) {
if (closes.length < period) return null;
const slice = closes.slice(-period);
return slice.reduce((s, v) => s + v, 0) / period;
}
function calcRSI(closes, period = 14) {
if (closes.length < period + 1) return null;
let gains = 0, losses = 0;
for (let i = closes.length - period; i < closes.length; i++) {
const diff = closes[i] - closes[i - 1];
if (diff > 0) gains += diff;
else losses -= diff;
}
// A perfectly flat series has no gains OR losses — that's neutral (50),
// not overbought. Falling through to the losses===0 branch would report
// RSI 100 and flip momentum hard negative on a stock that did nothing.
if (gains === 0 && losses === 0) return 50;
if (losses === 0) return 100;
const rs = (gains / period) / (losses / period);
return 100 - (100 / (1 + rs));
}
// ── MOVE SIZING ─────────────────────────────────────────
// Torn stocks barely move: across the live market (24 stocks, 17 Aug 2026)
// a full WEEK spans −1.81%…+1.88%, sd 0.94% — a daily sd of ~0.37%. Any
// fixed "the price moved meaningfully" percentage borrowed from real-market
// intuition is therefore off by an order of magnitude and silently makes a
// signal unreachable. Every such test sizes against the stock's own vol24h.
//
// volOverHours scales that typical 24h move to another horizon by √time, so
// an hour, a week and a month all get a bar derived from the same measured
// number. `floorPct` keeps a dead-flat stock (or one with no vol estimate
// yet) from qualifying on rounding noise.
// Percentages in signal text: 2 decimals below 1%, else 1. A genuine Torn
// move is usually a fraction of a percent, and toFixed(1) renders most of
// them as a meaningless "0.0%".
const fmtPct = v => (Math.abs(v) < 1 ? v.toFixed(2) : v.toFixed(1));
const NOTABLE_MOVE_MULT = 1.5; // ≈1.5σ — a move worth naming
const HOUR_MOVE_MIN_PCT = 0.03;
const WEEK_MOVE_MIN_PCT = 0.25;
const MONTH_MOVE_MIN_PCT = 0.5;
function volOverHours(vol24h, hours, floorPct) {
const scaled = (vol24h === null || vol24h === undefined)
? null
: vol24h * Math.sqrt(hours / 24);
if (scaled === null || !(scaled > 0)) return floorPct;
return Math.max(floorPct, scaled);
}
function analyzeOHLC(candles, rsiPeriod = 14) {
if (!candles || candles.length < 20) return null;
const closes = candles.map(c => parseFloat(c[4])); // close price
const current = closes[closes.length - 1];
// SMAs
const sma7 = calcSMA(closes, 7);
const sma20 = calcSMA(closes, 20);
const sma50 = calcSMA(closes, Math.min(50, closes.length));
// RSI — period is configurable per investor profile (hourly candles)
const rsi = calcRSI(closes, rsiPeriod);
// Price vs SMAs
const aboveSMA7 = sma7 && current > sma7;
const aboveSMA20 = sma20 && current > sma20;
// 1-day, 7-day and 30-day price change
const change24h = closes.length >= 24 ? ((current - closes[closes.length - 24]) / closes[closes.length - 24]) * 100 : null;
const change7d = closes.length >= 168 ? ((current - closes[closes.length - 168]) / closes[closes.length - 168]) * 100 : null;
const change30d = closes.length >= 720 ? ((current - closes[closes.length - 720]) / closes[closes.length - 720]) * 100 : null;
// 30-day high/low for range position
const recent = closes.slice(-720);
const high30 = Math.max(...recent);
const low30 = Math.min(...recent);
const rangePos = high30 > low30 ? ((current - low30) / (high30 - low30)) * 100 : 50;
// Typical 24h move, in %, for THIS stock — the stdev of its hourly returns
// scaled to a day. Torn stocks are far quieter than real markets (a whole
// week is often under 1%), and quiet ones are quieter still, so any
// "the price moved meaningfully" test has to be sized against the stock's
// own volatility rather than a fixed percentage. See isBuyLowSetup().
let vol24h = null;
if (recent.length >= 25) {
const rets = [];
for (let i = 1; i < recent.length; i++) {
if (recent[i - 1] > 0) rets.push((recent[i] - recent[i - 1]) / recent[i - 1]);
}
if (rets.length > 1) {
const mean = rets.reduce((s, v) => s + v, 0) / rets.length;
const variance = rets.reduce((s, v) => s + (v - mean) ** 2, 0) / rets.length;
vol24h = Math.sqrt(variance) * Math.sqrt(24) * 100;
}
}
// Momentum score: -3 to +3
let momentum = 0;
if (rsi !== null) {
if (rsi < 30) momentum += 1.5; // oversold = buy signal
else if (rsi < 40) momentum += 0.5;
else if (rsi > 70) momentum -= 1.5; // overbought = caution
else if (rsi > 60) momentum -= 0.5;
}
if (aboveSMA7) momentum += 0.5;
if (aboveSMA20) momentum += 0.5;
if (change7d !== null) {
// Was ±3%. Measured against the live market (24 stocks, 17 Aug 2026)
// a whole WEEK spans −1.81%…+1.88% with sd 0.94%, so ±3% is a 3σ event
// and this term contributed to momentum essentially never. Size it to
// the stock's own weekly move instead — same fix BUY LOW got in 1.16.2.
const wk = volOverHours(vol24h, 168, WEEK_MOVE_MIN_PCT);
if (change7d > NOTABLE_MOVE_MULT * wk) momentum += 0.5;
else if (change7d < -NOTABLE_MOVE_MULT * wk) momentum -= 0.5;
}
// Price signal label
let signal, signalColor;
if (momentum >= 1.5) { signal = 'Going Up'; signalColor = '#4caf50'; }
else if (momentum >= 0.5) { signal = 'Slightly Up'; signalColor = '#81c784'; }
else if (momentum <= -1.5) { signal = 'Going Down'; signalColor = '#f44336'; }
else if (momentum <= -0.5) { signal = 'Slightly Down'; signalColor = '#e57373'; }
else { signal = 'Flat'; signalColor = '#888'; }
return {
sma7, sma20, sma50, rsi,
change24h, change7d, change30d, rangePos, vol24h,
momentum, signal, signalColor,
aboveSMA7, aboveSMA20
};
}
// Smallest 24h move that counts as a recovery, in %. Sized against the
// stock's OWN typical daily move (vol24h) rather than a fixed percentage.
// This is the fix for the "no BUY LOW signals" bug: v1.16.0 hardcoded 1%,
// but Torn stocks routinely move under 1% across a whole WEEK, so the bar
// sat several times above anything the market actually does and the signal
// was unreachable for every profile. The absolute floor keeps a dead-flat
// stock from qualifying on rounding noise.
const BUY_DIP_MIN_MOVE_PCT = 0.1;
function buyDipThreshold(t, volMult = 1.25) {
const vol = t && t.vol24h !== null && t.vol24h !== undefined ? t.vol24h : null;
// No volatility estimate (short history) — fall back to the floor rather
// than to a made-up percentage.
if (vol === null) return BUY_DIP_MIN_MOVE_PCT;
return Math.max(BUY_DIP_MIN_MOVE_PCT, volMult * vol);
}
// Is this stock a BUY LOW setup — genuinely depressed AND turning back up?
// Kept pure (and exported for tests) because its two halves are easy to get
// subtly wrong, and getting them wrong makes the signal vanish silently.
// depressed — bottom third of the 30-day range, or oversold RSI
// recovering — back above SMA7, up by more than the stock's usual daily
// move, and still in the bottom half of the 30-day range
//
// Deliberately does NOT test `momentum >= 0`. That clause (added in 1.16.0)
// re-imported the very RSI penalty it was meant to remove: momentum is
// dominated by RSI, RSI is measured over the active profile's own lookback,
// and on a short lookback (Day Trader = 14h) that window contains nothing
// but the recovery itself — so a dip-and-bounce scores RSI ~93 and momentum
// −1. The dead-cat-bounce guard belongs on price level, not on the bounce's
// own RSI: the rangePos ceiling keeps a stock that has already climbed back
// into the top half of its range from being called "near its recent low",
// which is what the momentum gate was really protecting against.
//
// Returns which gates passed and, for the ones that didn't, a plain-language
// reason. `renderSignals` shows the near-misses so "no BUY LOW today" can be
// told apart from "the signal is broken again" — the question this whole
// area of the script keeps generating.
function explainBuyLow(t, volMult = 1.25) {
if (!t) return { ok: false, blockers: ['no price history for this stock yet'], threshold: null };
const threshold = buyDipThreshold(t, volMult);
const pct = fmtPct;
const blockers = [];
// Half 1 — is it actually low?
if (!(t.rangePos <= 35 || (t.rsi !== null && t.rsi < 40))) {
blockers.push(`not low enough — sitting ${t.rangePos.toFixed(0)}% up its 30-day range${t.rsi !== null ? ` with RSI ${t.rsi.toFixed(0)}` : ''} (needs bottom 35%, or RSI under 40)`);
}
// Half 2 — is it actually turning up?
if (!t.aboveSMA7) blockers.push('still below its 7-hour average — falling, not recovering yet');
if (t.change24h === null) {
blockers.push('not enough history to measure a 24h move');
} else if (t.change24h < threshold) {
blockers.push(t.change24h < 0
? `still down ${pct(Math.abs(t.change24h))}% over 24h (needs to be up ${pct(threshold)}%)`
: `only up ${pct(t.change24h)}% over 24h — needs ${pct(threshold)}% to count as a real bounce for this stock`);
}
if (t.rangePos > 50) blockers.push(`already climbed back to ${t.rangePos.toFixed(0)}% of its 30-day range — too late to call it low`);
return { ok: blockers.length === 0, blockers, threshold };
}
function isBuyLowSetup(t, volMult = 1.25) {
return explainBuyLow(t, volMult).ok;
}
// Tornsy data cache (fetched once per session, refreshed every 10 min)
let tornsyCache = {};
let tornsyLastFetch = 0;
const TORNSY_TTL = 600_000; // 10 min
async function fetchTornsyData() {
if (Date.now() - tornsyLastFetch < TORNSY_TTL && Object.keys(tornsyCache).length > 0) return;
// Fetch hourly candles for all stocks we know about
const acronyms = Object.values(marketData || {}).map(s => s.acronym).filter(Boolean);
const batchSize = 5; // fetch 5 at a time to be gentle
for (let i = 0; i < acronyms.length; i += batchSize) {
const batch = acronyms.slice(i, i + batchSize);
const results = await Promise.all(batch.map(a => tornsyFetch(a, 'h1')));
batch.forEach((acronym, idx) => {
// Keep previously-fetched candles when a refetch fails or comes back
// empty — overwriting with [] would blank out trend/RSI for that
// stock for a full TORNSY_TTL window.
if (results[idx] && results[idx].length > 0) {
tornsyCache[acronym] = results[idx];
}
});
if (i + batchSize < acronyms.length) {
await new Promise(r => setTimeout(r, 200)); // small delay between batches
}
}
tornsyLastFetch = Date.now();
}
async function fetchAll() {
if (Date.now() - lastFetch < CACHE_TTL && marketData && portfolioData) return;
const [market, portfolio, bankRes] = await Promise.all([
tornFetch('torn/?selections=stocks'),
tornFetch('user/?selections=stocks,money'),
tornFetch('torn/?selections=bank').catch(e => {
console.warn('[Stock Advisor] Bank APR fetch failed, using cached values:', e);
return null;
})
]);
marketData = market.stocks;
portfolioData = portfolio.stocks || {};
// Detect active bank investment so we don't tell the user to move BB
// capital into a bank slot they can't actually use right now.
const cb = portfolio.city_bank || null;
if (cb && Number(cb.amount) > 0 && Number(cb.time_left) > 0) {
bankInvestment = {
active: true,
amount: Number(cb.amount),
timeLeft: Number(cb.time_left)
};
} else {
bankInvestment = { active: false, amount: 0, timeLeft: 0 };
}
// Capture cash sources for top-up affordability checks. All come from
// the same money response — no extra API call. company_funds is ignored on
// purpose: it's company money earmarked for salaries, not personal cash.
const wallet = Number(portfolio.money_onhand) || 0;
const vault = Number(portfolio.vault_amount) || 0;
const cayman = Number(portfolio.cayman_bank) || 0;
userCash = {
wallet, vault, cayman,
immediate: wallet + vault
};
if (bankRes && bankRes.bank) {
const b = bankRes.bank;
const next = {
7: Number(b['1w']) || 0,
14: Number(b['2w']) || 0,
31: Number(b['1m']) || 0,
62: Number(b['2m']) || 0,
93: Number(b['3m']) || 0
};
bankAprTable = next;
_gmSet('tornStockAdvisor_bankAprCache', JSON.stringify(next));
}
lastFetch = Date.now();
// Record price snapshot for local history tracking
recordPrices(marketData);
}
// ── ANALYSIS ENGINE ─────────────────────────────────────
// Active benefit stocks allow multiple "increments": each successive increment
// costs 2× the previous, so cumulative shares for N increments = req × (2^N − 1)
// (per Torn wiki). Passive benefits are binary — requirement met or not.
function computeBenefitProgress(totalShares, benefit) {
if (!benefit || !benefit.requirement) {
return { bbCount: 0, sharesAboveBB: 0, sharesToNextBB: 0 };
}
const req = benefit.requirement;
if (benefit.type === 'active') {
if (totalShares < req) {
return { bbCount: 0, sharesAboveBB: 0, sharesToNextBB: req - totalShares };
}
const bbCount = Math.floor(Math.log2(totalShares / req + 1));
const cumulative = req * (Math.pow(2, bbCount) - 1);
const sharesAboveBB = totalShares - cumulative;
return {
bbCount,
sharesAboveBB,
sharesToNextBB: req * Math.pow(2, bbCount) - sharesAboveBB
};
}
const active = totalShares >= req;
return {
bbCount: active ? 1 : 0,
sharesAboveBB: active ? totalShares - req : 0,
sharesToNextBB: active ? 0 : req - totalShares
};
}
// The next stackable increment for a position that ALREADY owns at least one
// benefit block — i.e. "you're getting this perk, here's what doubling it
// costs". Returns null when there's nothing further to buy.
//
// This exists because every other buy path in the engine is gated on owning
// LESS than the requirement (`notHeldOrUnderBB` / `isPartialBB`), so once a
// block is complete the stock drops out of the buy side entirely and only
// KEEP is left. For passive benefits that's correct — they're binary, extra
// shares do nothing. For ACTIVE benefits it's wrong: they stack in doubling
// increments, so there is always a next thing to buy, and a player stacking
// cash toward it was being told nothing at all. `computeBenefitProgress`
// already worked out `sharesToNextBB` for exactly this case; nothing read it.
//
// Pure and exported for tests — the doubling arithmetic is the same trap
// computeBenefitProgress guards against, one level up.
function nextBenefitIncrement(position, benefit, price) {
if (!position || !benefit || !benefit.requirement) return null;
// Passive perks don't stack — anything above 1× the requirement is excess,
// which is SELL EXTRA's job, not a buy signal.
if (benefit.type !== 'active') return null;
// Below the requirement the TOP UP branch owns this stock; don't compete.
if (!position.hasBenefit || position.bbCount < 1) return null;
const shares = position.sharesToNextBB;
if (!shares || shares <= 0) return null;
// Increment size = what's already banked toward it + what's still missing.
// Avoids recomputing 2^bbCount and can't drift from computeBenefitProgress.
const incrementShares = position.sharesAboveBB + shares;
return {
shares,
cost: shares * price,
nextCount: position.bbCount + 1,
incrementShares,
// Full price of this increment (banked shares included) — the correct
// denominator for marginal ROI, unlike `cost` which is only the remainder.
incrementCost: incrementShares * price,
progressPct: incrementShares > 0 ? (position.sharesAboveBB / incrementShares) * 100 : 0
};
}
// ROI of the NEXT stackable increment, which is not the headline block ROI.
// Active benefits stack linearly — each further increment pays the same
// absolute amount per year as the first — while the share cost DOUBLES every
// time (req × 2^N). So the marginal return halves with every block already
// held, and quoting the headline figure on a STACK UP card overstates the
// next purchase by a factor of 2^bbCount:
//
// 1st block $1B → $100M/yr = 10%
// 2nd block $2B → $100M/yr = 5%
// 3rd block $4B → $100M/yr = 2.5%
//
// Denominator is the FULL increment cost, not the shares still to buy: the
// shares already banked toward it are capital committed to this perk too,
// and could otherwise be sold.
function marginalIncrementROI(annualValue, incrementCost) {
if (!(annualValue > 0) || !(incrementCost > 0)) return null;
return (annualValue / incrementCost) * 100;
}
function analyzeStock(id, stock) {
const held = portfolioData[id];
// Forecast/demand may not exist in Stocks 3.0 API — handle gracefully
const forecast = stock.forecast || null;
const demand = stock.demand || null;
const availPct = stock.total_shares > 0 ? (stock.available_shares / stock.total_shares) * 100 : 100;
// Price change from local history tracking (builds over time)
const priceChangeInfo = getPriceChange(id);
const priceChange = priceChangeInfo ? priceChangeInfo.pct : null;
const priceChangeSpanMin = priceChangeInfo ? priceChangeInfo.spanMin : null;
let benefitROI = null;
let bbCost = null;
let annualROI = 0;
if (stock.benefit && stock.benefit.requirement && stock.current_price > 0) {
bbCost = stock.benefit.requirement * stock.current_price;
const knownValue = BENEFIT_VALUES[id];
const freq = stock.benefit.frequency || 7;
benefitROI = {
cost: bbCost,
requirement: stock.benefit.requirement,
frequency: freq,
type: stock.benefit.type,
// API free-text — escaped once here because it's interpolated into
// innerHTML in reasons and cards downstream.
description: esc(stock.benefit.description),
payoutsPerYear: 365 / freq,
// Absolute annual value of ONE block — the numerator for marginal ROI
// on stackable increments, which can't be recovered from annualROI
// alone once the increment cost has doubled.
annualValue: knownValue ? knownValue.annual : 0,
note: knownValue ? knownValue.note : '',
tier: knownValue ? knownValue.tier : null
};
if (knownValue && knownValue.annual > 0) {
annualROI = (knownValue.annual / bbCost) * 100;
}
// Bank opportunity-cost comparison — only meaningful for cash-payout BBs.
// Passive tier S/A perks (TCB, WSU, WLT) aren't cash and shouldn't be suppressed.
if (knownValue && knownValue.annual > 0) {
const bank = getBankAprForFrequency(freq);
benefitROI.bankApr = bank.apr;
benefitROI.bankTermDays = bank.termDays;
benefitROI.bankTermLabel = bank.termLabel;
benefitROI.bankBeatsBB = bank.apr > 0 && bank.apr > annualROI;
benefitROI.bankDeltaPct = bank.apr > 0 ? (bank.apr - annualROI) : null;
// Bank is only an actionable alternative if the user can actually
// start a new investment right now. If they already have one active,
// freeing BB capital wouldn't help — flag it as informational only.
benefitROI.bankActionable = benefitROI.bankBeatsBB && !bankInvestment.active;
}
}
let position = null;
if (held) {
let txnShares = 0;
let totalCost = 0;
const txns = held.transactions || {};
for (const t of Object.values(txns)) {
txnShares += t.shares;
totalCost += t.shares * t.bought_price;
}
// Always prefer API's authoritative share count over transaction sums.
// Transaction sums can overcount when shares have been sold
// (buy transactions may remain in history after partial sells).
// Stocks 3.0 API may use 'total_shares' or 'shares' — check both.
const apiShares = held.total_shares ?? held.shares ?? null;
let totalShares = txnShares;
if (apiShares != null) {
totalShares = apiShares;
// Scale cost basis proportionally when transaction sum doesn't match
// (approximates avg cost after partial sells)
if (txnShares > totalShares && txnShares > 0) {
totalCost = totalCost * (totalShares / txnShares);
}
} else if (Object.keys(txns).length > 0) {
console.warn(`[Stock Advisor] No total_shares/shares field for stock ${id} (${stock.acronym}) — falling back to transaction sum (${txnShares}). API fields:`, Object.keys(held));
}
const avgBuy = totalShares > 0 ? totalCost / totalShares : 0;
const currentValue = totalShares * stock.current_price;
const costBasis = totalCost;
const pnl = currentValue - costBasis;
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0;
const hasBenefit = stock.benefit && totalShares >= stock.benefit.requirement;
const dividendReady = held.dividend && held.dividend.ready > 0;
const dividendProgress = held.dividend ? `${held.dividend.progress || 0}/${held.dividend.frequency || 7}d` : null;
// Partial BB state — user owns some shares but fewer than the requirement.
// Only counts as a "real" partial-BB attempt if the user has accumulated
// a meaningful chunk of the BB requirement; below the threshold the
// holding is treated as a token / BUY-LOW leftover and falls through to
// normal SELL/no-signal logic. Tier S/A perks (TCB, WSU, WLT) get a
// lower threshold so we don't lose visibility into early-stage pursuits.
const { bbCount, sharesAboveBB, sharesToNextBB } = computeBenefitProgress(totalShares, stock.benefit);
const bbProgressPct = (stock.benefit && stock.benefit.requirement)
? (totalShares / stock.benefit.requirement) * 100
: 0;
const partialMinPct = (benefitROI?.tier === 'S' || benefitROI?.tier === 'A')
? PARTIAL_BB_MIN_PCT_TIER_SA
: PARTIAL_BB_MIN_PCT;
const isPartialBB = !!(
stock.benefit
&& stock.benefit.requirement
&& totalShares > 0
&& totalShares < stock.benefit.requirement
&& bbProgressPct >= partialMinPct
);
position = {
totalShares,
avgBuy,
currentValue,
costBasis,
pnl,
pnlPct,
hasBenefit,
dividendReady,
dividendProgress,
bbCount,
sharesAboveBB,
isPartialBB,
bbProgressPct,
sharesToNextBB
};
if (stock.benefit && stock.benefit.requirement) {
console.log(`[Stock Advisor] ${stock.acronym} benefit (${stock.benefit.type}): requirement=${stock.benefit.requirement}, totalShares=${totalShares}, bbCount=${bbCount}, sharesAboveBB=${sharesAboveBB}, sharesToNextBB=${sharesToNextBB}`);
}
}
// Technical analysis from Tornsy data
const tornsyData = tornsyCache[stock.acronym];
const sp = getStyleProfile();
const technicals = analyzeOHLC(tornsyData, sp.rsiPeriod);
// Top-up affordability — only meaningful for partial BB holdings.
// `immediate` = wallet + personal vault; cayman is tracked separately
// because it has a 7d withdrawal delay and isn't actionable today.
let topUpCost = null;
let topUpAffordable = null;
let topUpShortfall = null;
let topUpAffordableWithCayman = null;
if (position && position.isPartialBB) {
topUpCost = position.sharesToNextBB * stock.current_price;
topUpAffordable = userCash.immediate >= topUpCost;
topUpShortfall = Math.max(0, topUpCost - userCash.immediate);
topUpAffordableWithCayman = (userCash.immediate + userCash.cayman) >= topUpCost;
}
return {
id,
name: stock.name,
acronym: stock.acronym,
price: stock.current_price,
forecast,
demand,
availPct,
buySignal: annualROI,
annualROI,
bbCost,
benefitROI,
position,
benefit: stock.benefit,
priceChange,
priceChangeSpanMin,
technicals,
topUpCost,
topUpAffordable,
topUpShortfall,
topUpAffordableWithCayman
};
}
function generateRecommendations(analyses) {
const recs = [];
const sp = getStyleProfile();
// Compute cross-portfolio rebalance opportunities up front so the fresh-BUY
// branch can defer to a REBALANCE card instead of emitting a naked BUY the
// user can't afford (and vice-versa). Shared with generateRebalanceRecommendations
// below to avoid recomputing.
const rebalanceOpps = findRebalanceOpportunities(analyses);
const rebalanceTargetIds = new Set(rebalanceOpps.map(o => Number(o.target.analysis.id)));
buyingPowerHiddenCount = 0;
profileHiddenBuyCount = 0;
noTechnicalsCount = 0;
buyLowNearMisses = [];
maxedPassiveCount = 0;
for (const a of analyses) {
if (!a.technicals) noTechnicalsCount++;
// Per-iteration shared state — needed by both the SELL/HOLD block and
// the technical-momentum block below, so it lives at the loop scope.
const isLocked = a.position ? lockedStocks.includes(Number(a.id)) : false;
// A position is "committed to a BB" if either the benefit is already
// unlocked OR the user is partway through accumulating one. In both
// cases the generic profit-take/stagnant SELL signals would conflict
// with the user's intent — they belong on the TOP UP / KEEP path.
const isCommittedToBB = !!(a.position && (a.position.hasBenefit || a.position.isPartialBB));
// The next stackable increment, for a position already on a full block.
// Null for passive perks (nothing more to buy) and for sub-block holdings
// (TOP UP's territory). Shared by the STACK UP card and KEEP's text.
const nextInc = a.position ? nextBenefitIncrement(a.position, a.benefit, a.price) : null;
const stackAffordable = !!(nextInc && userCash.immediate >= nextInc.cost);
// The return on THIS increment, not on the first block the user bought
// years ago. Halves with every block held, so it's the only figure that
// answers "is another one still worth it?".
const stackRoi = nextInc ? marginalIncrementROI(a.benefitROI?.annualValue, nextInc.incrementCost) : null;
if (a.position && a.position.hasBenefit && a.benefit && a.benefit.type !== 'active') {
maxedPassiveCount++;
}
// ── SELL / HOLD signals for held positions ──
if (a.position) {
// Payout is ready to collect!
if (a.position.dividendReady) {
recs.push({
stock: a,
action: 'CLAIM',
priority: 100,
reason: `You have a payout waiting — click to collect it!`,
color: '#e6b800'
});
}
// Sell extra shares above what's needed for the benefit (remainder
// beyond last complete block).
//
// PASSIVE benefits only. For an ACTIVE benefit `sharesAboveBB` is not
// excess at all — it's progress toward the next doubling increment, so
// calling it "extra shares you don't need" told a stacking player to
// sell the exact shares they'd been accumulating. STACK UP now speaks
// for those shares instead.
if (!isLocked && a.position.hasBenefit && a.benefit?.type !== 'active'
&& a.position.sharesAboveBB > 0 && a.position.pnlPct > 1) {
recs.push({
stock: a,
action: 'SELL EXTRA',
priority: 60 + a.position.pnlPct,
reason: `You're up ${a.position.pnlPct.toFixed(1)}%. You have ${fmtNum(a.position.sharesAboveBB)} extra shares you don't need for the benefit — sell them for profit`,
color: '#4caf50'
});
}
// Sell all if not locked, no benefit commitment, and profitable
if (!isCommittedToBB && !isLocked && a.position.pnlPct > sp.sellProfitThreshold) {
recs.push({
stock: a,
action: 'SELL',
priority: 50 + a.position.pnlPct,
reason: `You're up ${a.position.pnlPct.toFixed(1)}% and this stock's benefit isn't in your goals — good time to take your profit`,
color: '#4caf50'
});
}
// Profitable but stagnant — momentum flat or falling, not locked
if (sp.sellStagnantEnabled && !isCommittedToBB && !isLocked && a.position.pnlPct > sp.sellStagnantThreshold
&& a.technicals && a.technicals.momentum <= 0) {
recs.push({
stock: a,
action: 'SELL',
priority: 38 + Math.min(a.position.pnlPct, 10),
reason: `You're up ${a.position.pnlPct.toFixed(1)}% with no locked benefit and momentum is flat — lock in your gains before they disappear`,
color: '#81c784'
});
}
// Sell warning: losing money and price trend negative.
// Skipped entirely for any BB-committed position — the WAIT card from
// the TOP UP block is the canonical voice for partial-BB losses (it
// already mentions "cut losses and free $X" as a fallback). Letting
// both fire would produce duplicate competing recommendations.
// Require ≥10 min of local history before trusting the short-term
// drop — right after page load the window is a couple of minutes and
// a single tick can masquerade as a -1% "crash".
//
// The −1% bar this used to carry was a ~13σ hourly move at the real
// market's volatility — it never fired, so the "price is still
// falling" wording below was unreachable dead text. Size it to the
// stock's own move over the span actually observed.
const dropBar = a.priceChangeSpanMin !== null
? NOTABLE_MOVE_MULT * volOverHours(a.technicals?.vol24h ?? null, a.priceChangeSpanMin / 60, HOUR_MOVE_MIN_PCT)
: null;
const priceDropping = a.priceChange !== null && dropBar !== null
&& a.priceChange < -dropBar
&& a.priceChangeSpanMin !== null && a.priceChangeSpanMin >= 10;
// −2% over a week is 2.1σ here; kept but sized, so it tracks each
// stock rather than assuming they all move alike.
const weekBar = a.technicals
? NOTABLE_MOVE_MULT * volOverHours(a.technicals.vol24h, 168, WEEK_MOVE_MIN_PCT)
: null;
const trendNegative = a.technicals && (a.technicals.momentum <= -0.5 || (a.technicals.change7d !== null && a.technicals.change7d < -weekBar));
if (!isCommittedToBB && !isLocked && a.position.pnlPct < sp.sellLossThreshold && (priceDropping || trendNegative)) {
const spanLabel = a.priceChangeSpanMin >= 55 ? '~1h' : `${a.priceChangeSpanMin}m`;
const trendDetail = priceDropping
? `price is still falling (${fmtPct(a.priceChange)}% in the last ${spanLabel})`
: `price trend is negative (${a.technicals.change7d !== null ? fmtPct(a.technicals.change7d) + '% this week' : 'momentum is weak'})`;
recs.push({
stock: a,
action: 'SELL',
priority: 40,
reason: `You're down ${Math.abs(a.position.pnlPct).toFixed(1)}% and ${trendDetail} — sell to stop losing more`,
color: '#f44336'
});
}
// Loss warning (moderate) — same BB-commitment exemption as the deep-loss SELL above
if (!isCommittedToBB && !isLocked && a.position.pnlPct < sp.checkLossThreshold && !priceDropping && !trendNegative) {
recs.push({
stock: a,
action: 'REVIEW LOSS',
priority: 35,
reason: `You're down ${Math.abs(a.position.pnlPct).toFixed(1)}% with no benefit — think about selling to stop losses`,
color: '#ff9800'
});
}
// Stack up — this benefit is active-type, so every further increment
// adds another helping of the perk and the user can afford the next
// one. Without this card a player who already owns a block of
// everything gets KEEP on every stock and no answer to "where do I put
// my cash?". Priority and text both run on the MARGINAL return: the
// headline block ROI belongs to a purchase that already happened.
if (nextInc && stackAffordable && !isLocked) {
const perk = a.benefitROI?.description || a.benefitROI?.note || 'this benefit';
const progressNote = nextInc.progressPct >= 1
? ` You're already ${nextInc.progressPct.toFixed(0)}% of the way there.`
: '';
// Each increment pays the same as the first but costs twice the last
// one, so say what this one returns rather than implying it matches
// the block they already own.
const roiNote = stackRoi != null
? ` At $${fmtMoney(nextInc.incrementCost)} for the full increment that's ~${stackRoi.toFixed(1)}%/yr on the new money — half what your last block returns, because each one costs double.`
: '';
// Bank opportunity cost, re-checked against the MARGINAL return.
// A perk whose first block comfortably beat the bank often doesn't
// at 4× the price, and that's exactly when the card fires.
const bankApr = a.benefitROI?.bankApr || 0;
const bankNote = (stackRoi != null && bankApr > 0 && bankApr > stackRoi)
? ` Worth weighing: the bank's ${a.benefitROI.bankTermLabel} rate (${bankApr.toFixed(0)}%) currently beats that${bankInvestment.active ? ', though your bank investment is locked up' : ''}.`
: '';
recs.push({
stock: a,
action: 'STACK UP',
priority: 55 + (stackRoi || 0),
reason: `This perk stacks — you have ${a.position.bbCount}×. Buying ${fmtNum(nextInc.shares)} more shares ($${fmtMoney(nextInc.cost)}) takes you to ${nextInc.nextCount}×, adding another helping of ${perk}.${progressNote}${roiNote}${bankNote} You have $${fmtMoney(userCash.immediate)} cash.`,
color: '#4caf50'
});
}
// Keep — you're getting the benefit. Suppressed when STACK UP fired for
// this stock: that card already says "hold these and add more", so a
// second card would just repeat it lower down the list.
if (a.position.hasBenefit && a.position.pnlPct >= sp.keepFloor && !(nextInc && stackAffordable && !isLocked)) {
const bbLabel = a.position.bbCount > 1 ? `${a.position.bbCount}× benefit blocks` : 'the benefit';
let keepReason = `You're getting ${bbLabel} (next payout: ${a.position.dividendProgress}) — ${a.position.pnlPct >= 0 ? 'up' : 'down'} ${Math.abs(a.position.pnlPct).toFixed(1)}%`;
// Can't afford the next increment yet — still say what it would cost,
// so a stackable perk isn't silent about the one thing to save for.
if (nextInc && !stackAffordable) {
const stackRoiNote = stackRoi != null ? `, worth ~${stackRoi.toFixed(1)}%/yr on that increment` : '';
keepReason += ` — this perk stacks: ${fmtNum(nextInc.shares)} more shares ($${fmtMoney(nextInc.cost)}) would take you to ${nextInc.nextCount}×${stackRoiNote} (you have $${fmtMoney(userCash.immediate)})`;
}
// Warn if parking the same cash in the bank would earn more.
// If the user already has an active bank investment, the bank isn't
// a real alternative right now — make the note informational instead
// of telling them to "free capital" they can't actually redeploy.
if (a.benefitROI?.bankBeatsBB) {
if (bankInvestment.active) {
const days = Math.max(1, Math.round(bankInvestment.timeLeft / 86400));
keepReason += ` — note: bank ${a.benefitROI.bankTermLabel} APR (${a.benefitROI.bankApr.toFixed(0)}%) currently beats this BB's ${a.annualROI.toFixed(0)}%/yr, but your bank investment is locked for ~${days}d`;
} else {
keepReason += ` — note: bank ${a.benefitROI.bankTermLabel} APR (${a.benefitROI.bankApr.toFixed(0)}%) currently beats this BB's ${a.annualROI.toFixed(0)}%/yr; consider freeing capital`;
}
}
recs.push({
stock: a,
action: 'KEEP',
priority: 10,
reason: keepReason,
color: '#2196f3'
});
}
}
// ── BUY / TOP UP signals — only if ROI is compelling or benefit is S/A tier ──
const notHeldOrUnderBB = !a.position || (a.benefit && a.position && a.position.totalShares < a.benefit.requirement);
if (notHeldOrUnderBB && a.benefit) {
const sharesNeeded = a.position ? a.benefit.requirement - a.position.totalShares : a.benefit.requirement;
const tier = a.benefitROI?.tier;
// ── TOP UP branch — user already holds part of a BB ──
// Distinct from a fresh BUY: shows progress, top-up DELTA cost (not full
// BB cost), and branches on whether the user can actually afford it.
if (a.position && a.position.isPartialBB) {
const pctStr = a.position.bbProgressPct.toFixed(0);
const heldStr = fmtNum(a.position.totalShares);
const reqStr = fmtNum(a.benefit.requirement);
const deltaStr = fmtNum(a.position.sharesToNextBB);
const topUpStr = fmtMoney(a.topUpCost);
const idleStr = fmtMoney(a.position.currentValue);
const cashStr = fmtMoney(userCash.immediate);
// Perk description: prefer the rich label from BENEFIT_VALUES, else
// fall back to a generic "X%/yr payout" framing.
const perkDesc = (tier === 'S' || tier === 'A') && (a.benefitROI?.description || a.benefitROI?.note)
? (a.benefitROI.description || a.benefitROI.note)
: a.annualROI > 0
? `the ~${a.annualROI.toFixed(0)}%/yr payout`
: 'the benefit';
// Tier S/A partial holdings deserve higher visibility.
const tierBoost = tier === 'S' ? 80 + sp.buyTierSPriorityBoost
: tier === 'A' ? 70 + sp.buyTierAPriorityBoost
: 0;
const tierPrefix = tier === 'S' ? 'Best in class! '
: tier === 'A' ? 'Great perk: '
: '';
// Cayman footnote — only when Cayman bridges the gap that immediate
// cash can't, so the user knows there's a slower path.
const caymanFootnote = (userCash.cayman > 0 && a.topUpAffordable === false && a.topUpAffordableWithCayman)
? ` (with Cayman, $${fmtMoney(userCash.cayman)} more is available after a 7d withdrawal delay)`
: '';
// Priority scales with ROI (mirroring fresh BUY) so a high-yield
// partial BB doesn't get buried under generic BUY cards.
// Affordable top-ups beat fresh BUYs of equivalent ROI by +10
// because finishing > starting when capital's already committed.
const roiBoost = a.annualROI || 0;
if (a.topUpAffordable) {
// Affordable — green-light TOP UP
recs.push({
stock: a,
action: 'TOP UP',
priority: 60 + roiBoost + tierBoost,
reason: `${tierPrefix}You have ${pctStr}% of a benefit block (${heldStr} / ${reqStr} shares, $${idleStr} idle). Add ${deltaStr} shares ($${topUpStr}) to unlock ${perkDesc} — you have $${cashStr} cash.`,
color: '#4caf50'
});
} else if (a.position.pnlPct >= sp.sellProfitThreshold) {
// Not affordable, but profit is meaningful enough that selling is a real option
const profitStr = fmtMoney(a.position.pnl);
const shortStr = fmtMoney(a.topUpShortfall);
recs.push({
stock: a,
action: 'TOP UP / SELL',
priority: 45 + roiBoost / 2 + tierBoost / 2,
reason: `${tierPrefix}Partial BB earning $0 — and you're up ${a.position.pnlPct.toFixed(1)}%. Top up needs $${topUpStr} (short $${shortStr}). Either save up, or sell ${heldStr} shares for ~$${profitStr} profit and redeploy.${caymanFootnote}`,
color: '#ff9800'
});
} else if (a.position.pnlPct > 0) {
// Not affordable, small profit — round-trip fees would eat the gain. Hold.
const shortStr = fmtMoney(a.topUpShortfall);
recs.push({
stock: a,
action: 'HOLD & SAVE',
priority: 30 + roiBoost / 4 + tierBoost / 2,
reason: `${tierPrefix}Partial BB earning $0. Top up needs $${topUpStr} (you have $${cashStr}, short $${shortStr}). Profit (${a.position.pnlPct.toFixed(1)}%) too small to justify a round-trip — hold and save toward the block.${caymanFootnote}`,
color: '#78909c'
});
} else {
// Not affordable, flat or losing. Tone scales with loss depth so
// a deeply-down position doesn't read as cheerfully optimistic:
// - shallow loss: lead with hold-and-save
// - deep loss (below sellLossThreshold): lead with cut-losses
const shortStr = fmtMoney(a.topUpShortfall);
const freedStr = fmtMoney(a.position.currentValue);
const lossPctStr = Math.abs(a.position.pnlPct).toFixed(1);
const isDeepLoss = a.position.pnlPct < sp.sellLossThreshold;
const reason = isDeepLoss
? `${tierPrefix}Partial BB earning $0 and down ${lossPctStr}% — and you can't afford to top up ($${topUpStr} needed, short $${shortStr}). Strongly consider cutting losses to free $${freedStr} before the position bleeds further.${caymanFootnote}`
: `${tierPrefix}Partial BB earning $0 and down ${lossPctStr}%. Top up needs $${topUpStr} (short $${shortStr}). Hold and save toward the block, or cut losses and free $${freedStr}.${caymanFootnote}`;
recs.push({
stock: a,
// A deep loss leads with "cut losses" — labelling that card
// HOLD & SAVE would contradict its own advice.
action: isDeepLoss ? 'REVIEW LOSS' : 'HOLD & SAVE',
// Bump priority on deep losses so the user sees the warning
priority: (isDeepLoss ? 50 : 30) + roiBoost / 4 + tierBoost / 2,
reason,
color: isDeepLoss ? '#f44336' : '#78909c'
});
}
}
// ── Fresh BUY branch — user owns 0 shares (or under-BB, non-partial) ──
// A full benefit block can cost billions, so gate on real buying power
// (detected instant cash). buyCost matches the rebalance engine's
// target.cost (sharesNeeded × price) exactly, so the two engines agree.
else {
// Would this stock earn a buy card at all? (Mirrors the tier ladder
// below.) If not, stay silent — not every stock deserves a rec.
const wouldBuy = tier === 'S' || tier === 'A'
|| (a.annualROI > 15 && !a.benefitROI?.bankActionable)
|| (sp.maybeBuyEnabled && a.annualROI > 5 && !a.benefitROI?.bankActionable);
// Tally mid-ROI blocks that only the profile is holding back, so an
// empty Signals tab can say WHY instead of just going quiet.
if (!wouldBuy && !sp.maybeBuyEnabled && a.annualROI > 5 && !a.benefitROI?.bankActionable) {
profileHiddenBuyCount++;
}
if (wouldBuy) {
const buyCost = sharesNeeded * a.price;
const immediate = userCash.immediate;
// Only gate when we actually know the user has cash to compare
// against; if immediate is 0 (unreadable/broke) never hide.
const gateOn = immediate > 0;
const affordableNow = !gateOn || buyCost <= immediate;
const hasRebalancePath = rebalanceTargetIds.has(Number(a.id));
if (affordableNow) {
// Affordable with cash — existing BUY tiers, unchanged.
if (tier === 'S') {
recs.push({
stock: a,
action: 'BUY',
priority: 80 + sp.buyTierSPriorityBoost,
reason: `Best in class! ${a.benefitROI.description || a.benefitROI.note} — just hold ${fmtNum(sharesNeeded)} shares (costs $${fmtMoney(a.bbCost)})`,
color: '#ffd700'
});
} else if (tier === 'A') {
recs.push({
stock: a,
action: 'BUY',
priority: 70 + sp.buyTierAPriorityBoost,
reason: `Great perk: ${a.benefitROI.description || a.benefitROI.note} — hold ${fmtNum(sharesNeeded)} shares (costs $${fmtMoney(a.bbCost)})`,
color: '#00bcd4'
});
} else if (a.annualROI > 15 && !a.benefitROI?.bankActionable) {
// High return from payouts (>15%/yr) — unless bank term APR beats
// it AND the user can deposit (no active investment locking it).
recs.push({
stock: a,
action: 'BUY',
priority: 50 + a.annualROI + sp.buyHighRoiPriorityBoost,
reason: `Good earner! Pays ~${a.annualROI.toFixed(0)}% per year in rewards — hold ${fmtNum(sharesNeeded)} shares (costs $${fmtMoney(a.bbCost)})`,
color: '#4caf50'
});
} else {
// Decent return (5-15%/yr) — maybeBuy gate already in wouldBuy.
recs.push({
stock: a,
action: 'MAYBE BUY',
priority: 20 + a.annualROI + sp.maybeBuyPriorityBoost,
reason: `Decent earner — pays ~${a.annualROI.toFixed(0)}% per year in rewards — costs $${fmtMoney(a.bbCost)}`,
color: '#78909c'
});
}
} else if (hasRebalancePath) {
// Can't afford with cash, but the rebalance engine can fund it by
// selling other holdings — defer to its REBALANCE card (no duplicate).
} else {
// No cash, no rebalance path. If it's within reach soon (saving a
// little, or via Cayman) and the perk is worth it, nudge to save
// toward it; otherwise it's out of reach — hide and tally.
const reachSoon = buyCost <= (immediate + userCash.cayman) * 1.5;
const worthy = tier === 'S' || tier === 'A' || a.annualROI > 15;
if (reachSoon && worthy) {
const costStr = fmtMoney(buyCost);
const overStr = fmtMoney(buyCost - immediate);
const budgetStr = fmtMoney(immediate);
const perk = (tier === 'S' || tier === 'A') && (a.benefitROI?.description || a.benefitROI?.note)
? (a.benefitROI.description || a.benefitROI.note)
: a.annualROI > 0 ? `~${a.annualROI.toFixed(0)}%/yr in rewards` : 'this benefit';
const caymanNote = (userCash.cayman > 0 && buyCost > immediate && buyCost <= immediate + userCash.cayman)
? ` Your Cayman balance ($${fmtMoney(userCash.cayman)}) would cover the gap after a 7d withdrawal.`
: '';
recs.push({
stock: a,
action: 'SAVE TOWARD',
priority: 14 + (a.annualROI || 0) / 4,
reason: `${perk} — but a full block costs $${costStr}, about $${overStr} over your $${budgetStr} buying power. Worth saving toward.${caymanNote}`,
color: '#78909c'
});
} else {
buyingPowerHiddenCount++;
}
}
}
}
}
// ── PRICE MOMENTUM signals (any stock, held or not) ──
if (a.technicals) {
const t = a.technicals;
// BUY LOW used to require `!a.position` — owning a single share of a
// stock disabled its entry signal permanently, and silenced its
// near-miss line too, so a player holding a bit of everything saw an
// empty Signals tab with nothing explaining why. Gate on
// `isCommittedToBB` instead, matching every other momentum signal: a
// holding that isn't pledged to a benefit block can still be worth
// adding to cheaply. (Contradiction with a same-stock SELL card is
// handled by the exit-family dedup below.)
const buyLow = sp.buyDipEnabled && !isCommittedToBB
? explainBuyLow(t, sp.buyDipVolMult)
: null;
// One gate away from a BUY LOW — worth reporting when nothing fires.
if (buyLow && !buyLow.ok && buyLow.blockers.length === 1) {
// How close the 24h move got to the bar, so the list can lead with
// the stock that's actually nearest rather than whatever came first.
const nearness = buyLow.threshold && t.change24h !== null
? t.change24h / buyLow.threshold
: -Infinity;
buyLowNearMisses.push({ acronym: a.acronym, blocker: buyLow.blockers[0], nearness });
}
if (buyLow && buyLow.ok) {
// Torn moves are small in absolute terms — a "big" day is well under
// 1% — so one decimal place rounds the whole signal away. Show two
// below 1%, and say how the move compares to this stock's normal day
// so +0.4% reads as the meaningful jump it actually is.
const pct = fmtPct;
const details = [`+${pct(t.change24h)}% in 24h`];
if (t.vol24h > 0.02) details.push(`${(t.change24h / t.vol24h).toFixed(1)}× its usual daily move`);
if (t.change7d !== null) details.push(`${t.change7d >= 0 ? '+' : ''}${pct(t.change7d)}% this week`);
recs.push({
stock: a,
action: 'BUY LOW',
priority: 50 + sp.buyDipPriorityBoost,
reason: `${icon('trending-up', '#4caf50', 14)} Price is near its recent low and starting to recover (${details.join(', ')}) — could be a good entry for a short-term trade (buy now, sell after it climbs)`,
color: '#9c27b0'
});
}
// Price looks like it's peaking — sell while you're ahead.
// Skipped for partial BBs: the user is mid-commit, so "take profit"
// contradicts the TOP UP recommendation.
if (sp.sellNowEnabled && t.momentum <= -1 && a.position && a.position.pnlPct > sp.sellProfitThreshold && !isCommittedToBB && !isLocked) {
recs.push({
stock: a,
action: 'SELL NOW',
priority: 55 + sp.sellNowPriorityBoost,
reason: `${icon('trending-down', '#f44336', 14)} You're up ${a.position.pnlPct.toFixed(1)}% but price looks like it's peaking — sell before it drops`,
color: '#ff9800'
});
}
// Price falling + you're losing money + no BB commitment
if (sp.techSellEnabled && t.momentum <= -0.5 && a.position
&& a.position.pnlPct < (sp.techSellPnlOverride !== null ? sp.techSellPnlOverride : -2)
&& !isCommittedToBB && !isLocked) {
recs.push({
stock: a,
action: 'SELL',
priority: 42 + sp.techSellPriorityBoost,
reason: `${icon('trending-down', '#f44336', 14)} Price is falling and you're down ${Math.abs(a.position.pnlPct).toFixed(1)}% with no benefit — sell to limit losses`,
color: '#f44336'
});
}
// Price near 30-day high + you're profitable + no BB commitment = might drop soon
if (sp.sellSoonRangeEnabled && a.position && !isCommittedToBB && a.position.pnlPct > 0.5
&& t.rangePos > 75 && t.momentum <= 0.5 && !isLocked) {
recs.push({
stock: a,
action: 'SELL SOON',
priority: 48 + sp.sellSoonRangePriorityBoost,
reason: `${icon('warning', '#ff9800', 14)} You're up ${a.position.pnlPct.toFixed(1)}% and price is near its 30-day high — it could start dropping, consider locking in your profit`,
color: '#ff9800'
});
}
// RSI overbought — price may pull back
if (sp.sellSoonRsiEnabled && a.position && !isCommittedToBB && !isLocked
&& a.position.pnlPct >= (sp.sellSoonRsiMinPnl || 0) && t.rsi !== null && t.rsi > sp.sellSoonRsiThreshold) {
recs.push({
stock: a,
action: 'SELL SOON',
priority: 44 + sp.sellSoonRsiPriorityBoost,
reason: `${icon('warning', '#ff9800', 14)} RSI is ${t.rsi.toFixed(0)} (overbought) and you're up ${a.position.pnlPct.toFixed(1)}% — the price often drops after reaching these levels`,
color: '#ff9800'
});
}
// Held stock that had a big run-up and is now slowing down
// The +8% month this needed was ~3σ at the real market's volatility —
// WATCH never fired for anyone. Both legs are now sized to the stock:
// a genuinely big month, followed by a week that has gone quiet.
const monthBar = NOTABLE_MOVE_MULT * volOverHours(t.vol24h, 720, MONTH_MOVE_MIN_PCT);
const quietWeekBar = 0.5 * volOverHours(t.vol24h, 168, WEEK_MOVE_MIN_PCT);
if (sp.watchEnabled && a.position && !isCommittedToBB && a.position.pnlPct > 0
&& t.change7d !== null && t.change30d !== null
&& t.change30d > monthBar && t.change7d < quietWeekBar && t.momentum <= 0) {
recs.push({
stock: a,
action: 'WATCH',
priority: 30 + sp.watchPriorityBoost,
reason: `${icon('eye', '#78909c', 14)} Price went up ${fmtPct(t.change30d)}% this month but has slowed down this week (${t.change7d >= 0 ? '+' : ''}${fmtPct(t.change7d)}%) — the run might be ending`,
color: '#78909c'
});
}
}
}
// Lead the near-miss list with whatever got closest to the bar.
buyLowNearMisses.sort((x, y) => y.nearness - x.nearness);
// ── REBALANCE signals — cross-portfolio optimization ──
// Reuse the opportunities computed at the top of this function.
const rebalanceRecs = generateRebalanceRecommendations(rebalanceOpps);
recs.push(...rebalanceRecs);
// One exit-flavored card per stock. SELL (profit-take / stagnant / loss),
// SELL NOW, SELL SOON, and WATCH all say "get out (or get ready to)" with
// different urgency, and their trigger conditions overlap — a single stock
// could collect up to four of them in one refresh. Keep only the
// highest-priority one; the others are noise, not extra information.
const EXIT_FAMILY = new Set(['SELL', 'SELL NOW', 'SELL SOON', 'WATCH']);
const bestExit = new Map();
for (const r of recs) {
if (!EXIT_FAMILY.has(r.action)) continue;
const cur = bestExit.get(r.stock.id);
if (!cur || r.priority > cur.priority) bestExit.set(r.stock.id, r);
}
// BUY LOW can now fire on a stock the user already holds, which means it
// can collide with that stock's own exit card ("buy this" next to "sell
// this"). The exit card wins — it's about the position the user actually
// has, the entry is only a speculative add.
const deduped = recs.filter(r => {
if (r.action === 'BUY LOW') return !bestExit.has(r.stock.id);
return !EXIT_FAMILY.has(r.action) || bestExit.get(r.stock.id) === r;
});
deduped.sort((a, b) => b.priority - a.priority);
return deduped;
}
// ── REBALANCING ENGINE ──────────────────────────────────
function findRebalanceOpportunities(analyses) {
// Step 1: Find target benefits worth pursuing
const targets = analyses
.filter(a => a.benefit && !a.position?.hasBenefit)
.filter(a => a.benefitROI?.tier === 'S' || a.benefitROI?.tier === 'A' || a.annualROI > 0)
.map(a => {
const sharesNeeded = a.benefit.requirement - (a.position?.totalShares || 0);
const cost = sharesNeeded * a.price;
return { analysis: a, sharesNeeded, cost };
})
.sort((a, b) => {
const tierRank = { S: 100, A: 50 };
const ra = (tierRank[a.analysis.benefitROI?.tier] || 0) + a.analysis.annualROI;
const rb = (tierRank[b.analysis.benefitROI?.tier] || 0) + b.analysis.annualROI;
return rb - ra;
});
// Step 2: Find sellable holdings (exclude locked stocks)
const sources = analyses
.filter(a => a.position && !lockedStocks.includes(Number(a.id)))
.map(a => {
let sellableShares = 0;
if (!a.benefit) {
sellableShares = a.position.totalShares;
} else if (a.position.hasBenefit && a.benefit.type !== 'active' && a.position.sharesAboveBB > 0) {
// Passive only. On an active benefit those shares are progress
// toward the next doubling increment, not spare change — funding
// another stock with them undoes work the user is mid-way through.
sellableShares = a.position.sharesAboveBB;
} else if (!a.position.hasBenefit && !a.benefitROI?.tier && a.annualROI === 0) {
sellableShares = a.position.totalShares;
}
if (sellableShares === 0) return null;
const sellValue = sellableShares * a.price * (1 - SELL_FEE);
return { analysis: a, sellableShares, sellValue, pnlPct: a.position.pnlPct };
})
.filter(Boolean)
.sort((a, b) => b.pnlPct - a.pnlPct);
// Step 3: Match sources to targets
const usedSourceIds = new Set();
const opportunities = [];
for (const target of targets) {
const tier = target.analysis.benefitROI?.tier;
const availableSources = sources.filter(s => {
if (usedSourceIds.has(s.analysis.id)) return false;
if (s.analysis.id === target.analysis.id) return false;
if (tier === 'S') { if (s.pnlPct < -10) return false; }
else if (tier === 'A') { if (s.pnlPct < -5) return false; }
else { if (s.pnlPct < -2) return false; }
return true;
});
let accumulated = 0;
const sellPlan = [];
for (const source of availableSources) {
if (accumulated >= target.cost) break;
const needed = target.cost - accumulated;
const useValue = Math.min(source.sellValue, needed);
const useShares = Math.min(
Math.ceil(useValue / (source.analysis.price * (1 - SELL_FEE))),
source.sellableShares
);
const actualValue = useShares * source.analysis.price * (1 - SELL_FEE);
sellPlan.push({
analysis: source.analysis,
shares: useShares,
value: actualValue,
pnlPct: source.pnlPct
});
accumulated += actualValue;
}
const fundingPct = target.cost > 0 ? (accumulated / target.cost) * 100 : 0;
if (fundingPct < 50) continue;
if (!tier && fundingPct < 70) continue;
const annualBenefitValue = BENEFIT_VALUES[target.analysis.id]?.annual || 0;
const totalSellCost = sellPlan.reduce((sum, s) => {
const loss = s.pnlPct < 0 ? Math.abs(s.value * s.pnlPct / 100) : 0;
const fee = s.value * SELL_FEE / (1 - SELL_FEE);
return sum + loss + fee;
}, 0);
const breakEvenMonths = annualBenefitValue > 0 && totalSellCost > 0
? (totalSellCost / annualBenefitValue) * 12
: null;
if (!tier && (breakEvenMonths === null || breakEvenMonths > 24)) continue;
opportunities.push({ target, sellPlan, accumulated, fundingPct, breakEvenMonths, totalSellCost, annualBenefitValue });
for (const s of sellPlan) usedSourceIds.add(s.analysis.id);
}
return opportunities;
}
function buildRebalanceReason(opp) {
const target = opp.target;
const sellList = opp.sellPlan
.map(s => {
if (s.shares < s.analysis.position.totalShares) {
return `${fmtNum(s.shares)} extra ${s.analysis.acronym}`;
}
return s.analysis.acronym;
})
.join(', ')
.replace(/, ([^,]+)$/, ' and $1');
const benefitDesc = target.analysis.benefitROI?.description || target.analysis.benefitROI?.note || 'valuable benefit';
let reason;
if (opp.fundingPct >= 95) {
reason = `Sell ${sellList} to unlock ${target.analysis.acronym}'s benefit: ${benefitDesc}`;
if (opp.breakEvenMonths !== null && opp.breakEvenMonths < 24) {
reason += ` — pays for itself in ${opp.breakEvenMonths < 1 ? 'under a month' : Math.ceil(opp.breakEvenMonths) + ' months'}`;
} else {
reason += ` — no new cash needed`;
}
} else {
const remaining = target.cost - opp.accumulated;
reason = `Sell ${sellList} to cover ${Math.round(opp.fundingPct)}% of ${target.analysis.acronym}'s benefit: ${benefitDesc} — you'd still need $${fmtMoney(remaining)} cash`;
}
const lossSources = opp.sellPlan.filter(s => s.pnlPct < -1);
if (lossSources.length > 0) {
const worstLoss = Math.min(...lossSources.map(s => s.pnlPct));
reason += ` (includes selling ${lossSources[0].analysis.acronym} at ${Math.abs(worstLoss).toFixed(1)}% loss)`;
}
return reason;
}
function generateRebalanceRecommendations(opportunities) {
const sp = getStyleProfile();
return opportunities
// Drop targets the user can simply buy with cash — no need to suggest
// selling other holdings when immediate cash already covers the block.
// (Only when we actually know their cash: immediate > 0.)
.filter(opp => !(userCash.immediate > 0 && opp.target.cost <= userCash.immediate))
.map(opp => {
const tier = opp.target.analysis.benefitROI?.tier;
let priority;
if (tier === 'S') priority = 75;
else if (tier === 'A') priority = 65;
else priority = 55 + Math.min(opp.target.analysis.annualROI, 20);
priority += sp.rebalancePriorityBoost;
if (opp.fundingPct < 100) {
priority -= (100 - opp.fundingPct) / 5;
}
return {
stock: opp.target.analysis,
action: 'REBALANCE',
priority,
reason: buildRebalanceReason(opp),
color: '#ff6f00',
_rebalanceDetail: {
sellPlan: opp.sellPlan,
fundingPct: opp.fundingPct,
breakEvenMonths: opp.breakEvenMonths
}
};
});
}
// ── FORMATTING ──────────────────────────────────────────
function fmtMoney(n) {
if (n < 0) return '-' + fmtMoney(-n); // keep K/M/B abbreviation for negatives
if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
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.toFixed(2);
}
function fmtNum(n) {
return n.toLocaleString('en-US');
}
// HTML-escape for API-sourced free text that ends up in innerHTML.
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
}
// Parses user-typed money: "1m" → 1_000_000, "1.5b" → 1_500_000_000,
// "$2,000,000" → 2_000_000, "1k" → 1_000. Returns 0 on bad input.
function parseMoneyShort(s) {
if (s == null) return 0;
const str = String(s).trim().toLowerCase().replace(/[\s,$]/g, '');
if (!str) return 0;
const m = str.match(/^(-?\d+(?:\.\d+)?)([kmbt])?$/);
if (m) {
const num = parseFloat(m[1]);
const mul = m[2] === 'k' ? 1e3
: m[2] === 'm' ? 1e6
: m[2] === 'b' ? 1e9
: m[2] === 't' ? 1e12
: 1;
return Math.floor(num * mul);
}
const n = parseFloat(str);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
// ── SVG ICONS (Heroicons Mini 20x20) ───────────────────
const ICON_PATHS = {
'chart': 'M15.5 2A1.5 1.5 0 0 0 14 3.5v13A1.5 1.5 0 0 0 15.5 18h1A1.5 1.5 0 0 0 18 16.5v-13A1.5 1.5 0 0 0 16.5 2h-1ZM9.5 6A1.5 1.5 0 0 0 8 7.5v9A1.5 1.5 0 0 0 9.5 18h1A1.5 1.5 0 0 0 12 16.5v-9A1.5 1.5 0 0 0 10.5 6h-1ZM3.5 10A1.5 1.5 0 0 0 2 11.5v5A1.5 1.5 0 0 0 3.5 18h1A1.5 1.5 0 0 0 6 16.5v-5A1.5 1.5 0 0 0 4.5 10h-1Z',
'trending-up': 'M12.577 4.878a.75.75 0 0 1 .919-.53l4.78 1.281a.75.75 0 0 1 .531.919l-1.281 4.78a.75.75 0 0 1-1.449-.388l.82-3.063a41 41 0 0 0-8.476 7.456.75.75 0 0 1-1.212-.874 42.5 42.5 0 0 1 9.025-7.931l-3.187-.854a.75.75 0 0 1-.53-.919l.06-.147Z',
'trending-down': 'M12.577 15.122a.75.75 0 0 0 .919.53l4.78-1.281a.75.75 0 0 0 .531-.919l-1.281-4.78a.75.75 0 0 0-1.449.388l.82 3.063a41 41 0 0 1-8.476-7.456.75.75 0 0 0-1.212.874 42.5 42.5 0 0 0 9.025 7.931l-3.187.854a.75.75 0 0 0-.53.919l.06.147Z',
'warning': 'M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
'eye': 'M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z M.664 10.59a1.651 1.651 0 0 1 0-1.186A10.004 10.004 0 0 1 10 3c4.257 0 7.893 2.66 9.336 6.41.147.381.146.804 0 1.186A10.004 10.004 0 0 1 10 17c-4.257 0-7.893-2.66-9.336-6.41Z',
'check': 'M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z',
'square': 'M3.28 2.22a.75.75 0 0 0-1.06 1.06L3.28 4.34A2 2 0 0 0 2 6v8a2 2 0 0 0 2 2h12a2 2 0 0 0 .738-.141l1.042 1.042a.75.75 0 1 0 1.06-1.06l-1.04-1.041L3.28 2.22ZM13 16H4V6h1l8 8v2Zm3-1.06L7.06 6H16v8.94Z',
'bell': 'M4.214 3.227a.75.75 0 0 0-1.156-.956 8.97 8.97 0 0 0-1.856 3.826.75.75 0 0 0 1.466.316 7.47 7.47 0 0 1 1.546-3.186ZM16.942 2.271a.75.75 0 0 0-1.157.956 7.47 7.47 0 0 1 1.547 3.186.75.75 0 0 0 1.466-.316 8.97 8.97 0 0 0-1.856-3.826ZM10 2a6 6 0 0 0-6 6c0 1.887-.454 3.665-1.257 5.234a.75.75 0 0 0 .67 1.085h3.652a2.998 2.998 0 0 0 5.87 0h3.652a.75.75 0 0 0 .67-1.085A12.94 12.94 0 0 1 16 8a6 6 0 0 0-6-6Zm0 14.5a1.5 1.5 0 0 1-1.45-1.12h2.9A1.5 1.5 0 0 1 10 16.5Z',
'refresh': 'M4.755 10.059a7.5 7.5 0 0 1 12.548-3.364l1.903 1.903H14.25a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-1.5 0v4.056l-1.903-1.903A9 9 0 0 0 3.306 9.67a.75.75 0 1 0 1.45.388ZM15.245 9.941a7.5 7.5 0 0 1-12.548 3.364L.794 11.402H5.75a.75.75 0 0 0 0-1.5h-6.5a.75.75 0 0 0-.75.75v6.5a.75.75 0 0 0 1.5 0v-4.056l1.903 1.903A9 9 0 0 0 16.694 10.33a.75.75 0 1 0-1.45-.388Z',
'minus': 'M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z',
'plus': 'M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z',
'gear': 'M7.83922 1.80388C7.93271 1.33646 8.34312 1 8.81981 1H11.1802C11.6569 1 12.0673 1.33646 12.1608 1.80388L12.4913 3.45629C13.1956 3.72458 13.8454 4.10332 14.4196 4.57133L16.0179 4.03065C16.4694 3.8779 16.966 4.06509 17.2043 4.47791L18.3845 6.52207C18.6229 6.93489 18.5367 7.45855 18.1786 7.77322L16.9119 8.88645C16.9699 9.24909 17 9.62103 17 10C17 10.379 16.9699 10.7509 16.9119 11.1135L18.1786 12.2268C18.5367 12.5414 18.6229 13.0651 18.3845 13.4779L17.2043 15.5221C16.966 15.9349 16.4694 16.1221 16.0179 15.9693L14.4196 15.4287C13.8454 15.8967 13.1956 16.2754 12.4913 16.5437L12.1608 18.1961C12.0673 18.6635 11.6569 19 11.1802 19H8.81981C8.34312 19 7.93271 18.6635 7.83922 18.1961L7.50874 16.5437C6.80443 16.2754 6.1546 15.8967 5.58043 15.4287L3.98214 15.9694C3.5306 16.1221 3.03401 15.9349 2.79567 15.5221L1.61547 13.4779C1.37713 13.0651 1.4633 12.5415 1.82136 12.2268L3.08808 11.1135C3.03012 10.7509 3 10.379 3 10C3 9.62103 3.03012 9.2491 3.08808 8.88647L1.82136 7.77324C1.46331 7.45857 1.37713 6.93491 1.61547 6.52209L2.79567 4.47793C3.03401 4.06511 3.5306 3.87791 3.98214 4.03066L5.58042 4.57134C6.15459 4.10332 6.80442 3.72459 7.50874 3.45629L7.83922 1.80388ZM10 13C11.6569 13 13 11.6569 13 10C13 8.34315 11.6569 7 10 7C8.34315 7 7 8.34315 7 10C7 11.6569 8.34315 13 10 13Z',
'lock': 'M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z',
'lock-open': 'M14.5 1A4.5 4.5 0 0 0 10 5.5V9H3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-1.5V5.5a3 3 0 1 1 6 0v1a.75.75 0 0 0 1.5 0v-1A4.5 4.5 0 0 0 14.5 1Z',
};
function icon(name, color = 'currentColor', size = 16) {
const paths = ICON_PATHS[name];
if (!paths) return '';
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${color}" width="${size}" height="${size}" style="vertical-align:middle;flex-shrink:0">${paths.split(' M').map((d, i) => `<path fill-rule="evenodd" d="${i > 0 ? 'M' + d : d}" clip-rule="evenodd"/>`).join('')}</svg>`;
}
// ── UI INJECTION ────────────────────────────────────────
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
#tsa-panel {
position: fixed;
top: 60px;
right: 12px;
width: 420px;
max-height: calc(100vh - 80px);
background: #171717;
border: 1px solid #2a2a2a;
border-radius: 8px;
z-index: 999929;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
color: #e0e0e0;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
}
#tsa-panel.tsa-collapsed {
max-height: 40px;
overflow: hidden;
}
#tsa-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #1e1e1e;
border-radius: 8px 8px 0 0;
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
#tsa-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #ccc;
}
#tsa-header .tsa-controls {
display: flex;
gap: 8px;
align-items: center;
}
#tsa-header button {
background: none;
border: none;
color: #aaa;
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
}
#tsa-header button:hover { color: #fff; }
#tsa-tabs {
display: flex;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
#tsa-tabs button {
flex: 1;
padding: 6px 0;
background: transparent;
border: none;
color: #888;
cursor: pointer;
font-size: 12px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
#tsa-tabs button.active {
color: #aaa;
border-bottom-color: #aaa;
}
#tsa-tabs button:hover { color: #ccc; }
.tsa-find-btn {
background: transparent;
border: 1px solid #333;
border-radius: 4px;
color: #888;
font-size: 10px;
padding: 2px 6px;
cursor: pointer;
font-family: inherit;
margin-left: 6px;
line-height: 1.2;
}
.tsa-find-btn:hover { color: #ccc; border-color: #555; background: #222; }
/* Search bar injected above Torn's native stock list */
#tsa-page-search {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin: 8px 0;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 4px;
}
#tsa-page-search-input {
flex: 1;
padding: 7px 10px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #eee;
font-size: 13px;
font-family: inherit;
outline: none;
min-width: 0;
}
#tsa-page-search-input:focus { border-color: #4caf50; }
#tsa-page-search-input::-webkit-search-cancel-button { display: none; }
#tsa-page-search-clear {
background: transparent;
border: 1px solid #333;
border-radius: 4px;
color: #888;
font-size: 16px;
line-height: 1;
padding: 4px 10px;
cursor: pointer;
font-family: inherit;
}
#tsa-page-search-clear:hover { color: #fff; border-color: #555; background: #222; }
/* $ money input row injected into Torn's buy/sell modal */
.tsa-money-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 4px 4px;
font-family: inherit;
flex-wrap: wrap;
}
.tsa-money-label {
color: #999;
font-size: 12px;
flex-shrink: 0;
}
.tsa-money-input {
flex: 1;
min-width: 100px;
padding: 6px 10px;
background: #0f0f0f;
border: 1px solid #333;
border-radius: 4px;
color: #eee;
font-size: 13px;
font-family: inherit;
outline: none;
}
.tsa-money-input:focus { border-color: #4caf50; }
.tsa-money-result {
color: #999;
font-size: 12px;
flex-shrink: 0;
}
/* Hide TornTools' duplicate money input when ours is present. */
[class*="tt-money-input"] { display: none !important; }
/* Torn pins the buy/sell container to a fixed pixel height
(style="height: 210px") which clips our injected row on TornPDA's
narrow viewport. Force auto height once we've added a money row. */
[class*="manageContainer___"]:has(.tsa-money-row) { height: auto !important; min-height: 210px; }
#tsa-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}
.tsa-card {
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
display: flex;
gap: 10px;
align-items: flex-start;
}
.tsa-card:last-child { border-bottom: none; }
.tsa-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
min-width: 70px;
text-align: center;
}
.tsa-card-body {
flex: 1;
min-width: 0;
}
.tsa-stock-name {
font-weight: 600;
color: #fff;
font-size: 13px;
}
.tsa-stock-price {
color: #888;
font-size: 11px;
margin-left: 6px;
}
.tsa-reason {
color: #aaa;
font-size: 11px;
margin-top: 3px;
line-height: 1.4;
}
.tsa-meta {
display: flex;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
.tsa-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background: #2a2a2a;
color: #999;
}
.tsa-tag.good { background: #1b3a1b; color: #4caf50; }
.tsa-tag.bad { background: #3a1b1b; color: #f44336; }
.tsa-tag.neutral { background: #2a2a1b; color: #e6b800; }
#tsa-setup {
padding: 16px;
text-align: center;
}
#tsa-setup input {
width: 100%;
padding: 8px 10px;
background: #222;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 13px;
margin: 8px 0;
box-sizing: border-box;
}
#tsa-setup button {
padding: 6px 20px;
background: #4caf50;
border: none;
border-radius: 4px;
color: #000;
font-weight: 600;
cursor: pointer;
font-size: 13px;
}
#tsa-setup button:hover { background: #43a047; }
#tsa-status {
padding: 6px 12px;
font-size: 11px;
color: #666;
text-align: center;
border-top: 1px solid #2a2a2a;
flex-shrink: 0;
}
.tsa-empty {
padding: 20px;
text-align: center;
color: #666;
}
.tsa-style-card {
padding: 12px 14px;
margin: 6px 0;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.tsa-style-card:hover {
border-color: #555;
background: #252525;
}
.tsa-style-card.selected {
border-color: #4caf50;
background: #1b3a1b;
}
#tsa-tip {
padding: 4px 12px;
font-size: 10px;
color: #555;
text-align: center;
flex-shrink: 0;
}
#tsa-tip a {
color: #4caf50;
text-decoration: none;
}
#tsa-tip a:hover {
color: #81c784;
text-decoration: underline;
}
/* Portfolio overview row */
.tsa-portfolio-row {
padding: 8px 12px;
border-bottom: 1px solid #2a2a2a;
display: grid;
grid-template-columns: 22px 50px 1fr 70px 65px 50px;
gap: 6px;
align-items: center;
font-size: 12px;
}
.tsa-portfolio-row .tsa-stock-name { font-size: 12px; }
.tsa-portfolio-signal { font-size: 9px; font-weight: 600; padding: 1px 5px; border-radius: 3px; display: inline-block; margin-top: 2px; letter-spacing: 0.3px; }
.tsa-lock-btn { cursor: pointer; padding: 2px; line-height: 0; }
.tsa-pnl-pos { color: #4caf50; font-weight: 600; }
.tsa-pnl-neg { color: #f44336; font-weight: 600; }
.tsa-portfolio-header {
padding: 6px 12px;
display: grid;
grid-template-columns: 22px 50px 1fr 70px 65px 50px;
gap: 6px;
font-size: 10px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #2a2a2a;
background: #111;
white-space: nowrap;
}
/* Market Scan horizontal scroll wrapper — lets benefit text be read in full */
.tsa-market-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tsa-market-inner {
min-width: 100%;
width: max-content;
}
.tsa-rebalance-detail {
font-size: 10px;
color: #777;
margin-top: 4px;
padding: 4px 8px;
background: #111;
border-radius: 4px;
border-left: 2px solid #ff6f00;
line-height: 1.5;
}
/* Settings dropdown */
#tsa-settings-menu {
position: absolute;
right: 8px;
top: 38px;
background: #222;
border: 1px solid #2a2a2a;
border-radius: 6px;
z-index: 100001;
min-width: 150px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
display: none;
}
#tsa-settings-menu.open { display: block; }
#tsa-settings-menu button {
display: block;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
color: #ccc;
text-align: left;
cursor: pointer;
font-size: 12px;
}
#tsa-settings-menu button:hover { background: #2a2a2a; }
#tsa-settings-menu button:first-child { border-radius: 6px 6px 0 0; }
#tsa-settings-menu button:last-child { border-radius: 0 0 6px 6px; }
/* Stock abbreviation overlay on native stock logos */
[class*="logoContainer___"] {
position: relative !important;
}
.tsa-stock-abbr {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 1;
padding: 1px 3px;
border-radius: 2px;
letter-spacing: 0.5px;
pointer-events: none;
white-space: nowrap;
z-index: 2;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
}
/* Mobile / TornPDA responsive */
@media (max-width: 600px) {
#tsa-panel {
/* CLAUDE.md says never set position:relative on #tsa-panel — that
rule applies to the desktop overlay (which must stay fixed). On
mobile/PDA we deliberately switch to relative so the panel flows
inline with the page instead of floating over it. */
position: relative !important;
top: auto !important;
right: auto !important;
width: 100% !important;
max-height: none !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
z-index: 2 !important;
box-shadow: none !important;
}
#tsa-panel.tsa-collapsed {
max-height: 40px !important;
overflow: hidden;
}
#tsa-header { border-radius: 0; }
#tsa-header button {
min-width: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
#tsa-tabs button {
padding: 10px 0;
font-size: 13px;
}
#tsa-body {
max-height: 60vh;
overflow-y: auto;
}
.tsa-card {
padding: 12px 14px;
}
.tsa-stock-name { font-size: 14px; }
.tsa-reason { font-size: 12px; }
.tsa-portfolio-row {
grid-template-columns: 18px 42px 2fr 60px 3fr 42px;
}
.tsa-portfolio-row > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tsa-portfolio-row > span svg {
width: 10px;
height: 10px;
}
.tsa-portfolio-header {
grid-template-columns: 18px 42px 2fr 60px 3fr 42px;
white-space: nowrap;
}
.tsa-portfolio-header div {
overflow: hidden;
text-overflow: ellipsis;
}
#tsa-setup input {
font-size: 16px;
padding: 10px 12px;
}
#tsa-setup button {
padding: 10px 24px;
font-size: 14px;
}
}
/* Share calculator modal */
.tsa-clickable { cursor: pointer; }
.tsa-clickable:hover { background: #1d1d1d; }
#tsa-calc-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100002;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
#tsa-calc-modal {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
width: 100%;
max-width: 320px;
padding: 14px 16px;
color: #ccc;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
#tsa-calc-modal .tsa-calc-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
#tsa-calc-modal .tsa-calc-title {
font-size: 14px;
font-weight: 600;
color: #fff;
}
#tsa-calc-modal .tsa-calc-title small {
color: #888;
font-weight: 400;
margin-left: 6px;
}
#tsa-calc-modal .tsa-calc-close {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 22px;
line-height: 1;
padding: 0 6px;
}
#tsa-calc-modal .tsa-calc-close:hover { color: #fff; }
#tsa-calc-modal .tsa-calc-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
}
#tsa-calc-modal .tsa-calc-row label {
font-size: 11px;
color: #888;
min-width: 56px;
}
#tsa-calc-modal .tsa-calc-prefix {
color: #666;
font-size: 14px;
}
#tsa-calc-modal input {
flex: 1;
padding: 9px 10px;
background: #222;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 15px;
box-sizing: border-box;
font-family: inherit;
min-width: 0;
}
#tsa-calc-modal input:focus {
outline: none;
border-color: #4caf50;
}
#tsa-calc-modal .tsa-calc-quick {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
#tsa-calc-modal .tsa-calc-quick button {
flex: 1;
min-width: 60px;
padding: 6px 8px;
background: #222;
border: 1px solid #333;
border-radius: 4px;
color: #aaa;
font-size: 11px;
cursor: pointer;
font-family: inherit;
}
#tsa-calc-modal .tsa-calc-quick button:hover {
background: #2a2a2a;
color: #fff;
border-color: #555;
}
#tsa-calc-modal .tsa-calc-meta {
font-size: 10px;
color: #666;
border-top: 1px solid #2a2a2a;
padding-top: 8px;
margin-top: 12px;
line-height: 1.5;
}
/* Signal guide (glossary) modal */
#tsa-glossary-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100002;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
#tsa-glossary-modal {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
width: 100%;
max-width: 400px;
max-height: 80vh;
overflow-y: auto;
padding: 14px 16px;
color: #ccc;
font-size: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}
.tsa-glossary-group {
font-size: 10px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 10px 0 2px;
}
.tsa-glossary-row {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 6px 4px;
border-radius: 4px;
}
.tsa-glossary-row .tsa-badge { margin-top: 1px; }
.tsa-glossary-row.highlight {
background: #1b3a1b;
outline: 1px solid #4caf5044;
}
/* Badges that open the signal guide on tap */
[data-glossary] { cursor: pointer; }
`;
document.head.appendChild(style);
}
function createPanel() {
const panel = document.createElement('div');
panel.id = 'tsa-panel';
panel.innerHTML = `
<div id="tsa-header">
<h3>${icon('chart', '#ccc', 16)} Stock Advisor</h3>
<div class="tsa-controls">
<button id="tsa-settings-btn" title="Settings">${icon('gear', 'currentColor', 14)}</button>
<button id="tsa-refresh" title="Refresh">${icon('refresh', 'currentColor', 14)}</button>
<button id="tsa-toggle" title="Collapse" data-collapsed="false">${icon('minus', 'currentColor', 14)}</button>
</div>
</div>
<div id="tsa-settings-menu">
<button data-action="set-key">Set API Key</button>
<button data-action="investor-style">Investor Style</button>
<button data-action="bank-bonus">Bank APR Bonus</button>
<button data-action="signal-guide">Signal Guide</button>
<button data-action="clear-data">Clear Data</button>
<button data-action="about">About</button>
</div>
<div id="tsa-tabs">
<button class="active" data-tab="signals">Signals</button>
<button data-tab="portfolio">Portfolio</button>
<button data-tab="market">Market Scan</button>
</div>
<div id="tsa-body">
<div class="tsa-empty">Loading...</div>
</div>
<div id="tsa-status">Initializing...</div>
<div id="tsa-tip">Made by <a href="https://www.torn.com/profiles.php?XID=3702492" target="_blank">Nicram [3702492]</a> · Enjoying it? <a href="https://www.torn.com/trade.php#step=start&userID=3702492" target="_blank">Send a tip</a></div>
`;
// Insert panel: on mobile/PDA, place after sidebarroot so it appears below
// the stock list. On desktop, position:fixed means DOM order doesn't matter.
const sidebarRoot = document.querySelector('#sidebarroot');
if (sidebarRoot && sidebarRoot.parentNode) {
sidebarRoot.parentNode.insertBefore(panel, sidebarRoot.nextSibling);
} else {
const anchor = document.querySelector('#mainContainer') || document.querySelector('.content-wrapper') || document.body.firstChild;
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(panel, anchor);
} else {
document.body.insertBefore(panel, document.body.firstChild);
}
}
// Collapse toggle
document.getElementById('tsa-toggle').addEventListener('click', (e) => {
e.stopPropagation();
panel.classList.toggle('tsa-collapsed');
const btn = e.target.closest('button');
const collapsed = panel.classList.contains('tsa-collapsed');
btn.dataset.collapsed = collapsed;
btn.innerHTML = collapsed ? icon('plus', 'currentColor', 14) : icon('minus', 'currentColor', 14);
});
// Refresh
document.getElementById('tsa-refresh').addEventListener('click', async (e) => {
e.stopPropagation();
lastFetch = 0;
await refresh();
});
// Settings menu
const settingsMenu = document.getElementById('tsa-settings-menu');
document.getElementById('tsa-settings-btn').addEventListener('click', (e) => {
e.stopPropagation();
settingsMenu.classList.toggle('open');
});
// Close settings menu when clicking outside
document.addEventListener('click', () => settingsMenu.classList.remove('open'));
settingsMenu.addEventListener('click', (e) => {
e.stopPropagation();
const action = e.target.dataset?.action;
if (!action) return;
settingsMenu.classList.remove('open');
if (action === 'set-key') {
const key = prompt('Enter your Torn API key:\n\nMinimum required: Limited Access\n(Settings \u2192 API Keys \u2192 select Limited or higher)');
if (key && key.trim().length >= 10) {
apiKey = key.trim();
_gmSet('tornStockAdvisor_apiKey', apiKey);
lastFetch = 0;
refresh();
} else if (key !== null) {
alert('Invalid key \u2014 must be at least 10 characters.');
}
} else if (action === 'investor-style') {
showStyleSelection(false);
} else if (action === 'signal-guide') {
openGlossary();
} else if (action === 'bank-bonus') {
const fmtBase = (d) => bankAprTable[d] > 0 ? `${bankAprTable[d].toFixed(2)}%` : 'n/a';
const fmtEff = (d) => bankAprTable[d] > 0
? `${effectiveBankApr(bankAprTable[d], bankBonusPct).toFixed(2)}%`
: 'n/a';
const reviewNote = bankBonusNeedsReview
? 'NOTE: this setting changed. It used to be percentage POINTS\n' +
'added to the rate; it is now a percentage OF the rate, which\n' +
'is how Torn actually applies merits and TCB. Your old value\n' +
'could not be converted, so please re-enter it below.\n\n'
: '';
const baseInfo =
reviewNote +
'Base APRs from Torn API:\n' +
` 1w: ${fmtBase(7)} 2w: ${fmtBase(14)}\n` +
` 1m: ${fmtBase(31)} 2m: ${fmtBase(62)} 3m: ${fmtBase(93)}\n\n` +
`Your current bonus: +${bankBonusPct}% of the base rate\n` +
'Effective APRs (base x your bonus):\n' +
` 1w: ${fmtEff(7)} 2w: ${fmtEff(14)}\n` +
` 1m: ${fmtEff(31)} 2m: ${fmtEff(62)} 3m: ${fmtEff(93)}\n\n`;
const input = prompt(
baseInfo +
'Interest bonus, as a PERCENTAGE OF the base rate.\n' +
'Bank Interest Merits give +5% each (10 merits = +50%)\n' +
'and the TCB benefit block gives +10%.\n' +
'Example: 10 merits + TCB = type 60, so a 60% base\n' +
'rate becomes 60 x 1.60 = 96%.\n' +
'(Use 0 if none.)',
String(bankBonusPct)
);
if (input === null) return;
const parsed = Number(input.trim());
if (!Number.isFinite(parsed)) {
alert('Invalid number.');
return;
}
bankBonusPct = parsed;
bankBonusNeedsReview = false;
_gmSet('tornStockAdvisor_bankBonusRelPct', String(parsed));
refresh();
} else if (action === 'clear-data') {
if (confirm('Remove your stored API key and cached data?')) {
apiKey = '';
investorStyle = '';
_gmSet('tornStockAdvisor_apiKey', '');
_gmSet('tornStockAdvisor_priceHistory', '{}');
_gmSet('tornStockAdvisor_investorStyle', '');
priceHistory = {};
showSetup();
}
} else if (action === 'about') {
alert(
'Torn Stock Advisor v' + TSA_VERSION + '\n\n' +
'\u2022 Investor style: ' + getStyleProfile().label + '\n' +
'\u2022 No automated trading \u2014 display only' +
(IS_PDA ? '\n\u2022 TornPDA compatible' : '') +
'\n\nAPI USAGE\n' +
'\u2022 Access: Limited (read-only; no trading, no writes)\n' +
'\u2022 Key storage: Locally in your userscript manager only\n' +
'\u2022 Key shared with: Nobody\n' +
'\u2022 API data used: Stocks, money, bank rates\n' +
'\u2022 Torn API calls: 3 per refresh (60s interval), to api.torn.com only\n' +
'\u2022 External service: Tornsy is used for public stock price history\n' +
' (refreshed every 10 min); your Torn API key is never sent to Tornsy.'
);
}
});
// Tabs
document.getElementById('tsa-tabs').addEventListener('click', (e) => {
if (e.target.dataset.tab) {
document.querySelectorAll('#tsa-tabs button').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
renderTab(e.target.dataset.tab);
}
});
// Body-level click delegation: handles lock toggles on portfolio rows,
// a "find on page" action that filters Torn's native stock list to a
// single stock, and the $↔shares calculator trigger on any [data-calc]
// row/card. Lock and find take precedence so clicking those icons doesn't
// also fire the calc, since they sit inside the calc-targeted row.
panel.querySelector('#tsa-body').addEventListener('click', async (e) => {
const lockBtn = e.target.closest('[data-lock]');
if (lockBtn) {
const stockId = Number(lockBtn.dataset.lock);
const idx = lockedStocks.indexOf(stockId);
if (idx >= 0) lockedStocks.splice(idx, 1);
else lockedStocks.push(stockId);
await _gmSet('tornStockAdvisor_lockedStocks', JSON.stringify(lockedStocks));
if (cachedAnalyses.length > 0) {
cachedRecs = generateRecommendations(cachedAnalyses);
}
const activeTab = document.querySelector('#tsa-tabs button.active')?.dataset.tab;
if (activeTab) renderTab(activeTab);
return;
}
// Badge tap → signal guide. Must be checked before [data-find] and
// [data-calc] because badges sit inside cards/rows that carry those.
const badgeEl = e.target.closest('[data-glossary]');
if (badgeEl) {
e.stopPropagation();
openGlossary(badgeEl.dataset.glossary);
return;
}
const findBtn = e.target.closest('[data-find]');
if (findBtn) {
e.stopPropagation();
applyTornStockSearch(findBtn.dataset.find, { scroll: true });
return;
}
const calcEl = e.target.closest('[data-calc]');
if (calcEl) {
openShareCalc(calcEl.dataset.calc);
}
});
return panel;
}
function showSetup() {
const body = document.getElementById('tsa-body');
body.innerHTML = `
<div id="tsa-setup">
<p style="color:#aaa;margin:0 0 8px">Enter your Torn API key<br><span style="font-size:11px;color:#666">(Limited Access or higher)</span></p>
<input id="tsa-key-input" type="password" placeholder="Your API key..." />
<button id="tsa-key-save">Save & Start</button>
</div>
`;
document.getElementById('tsa-key-save').addEventListener('click', async () => {
const key = document.getElementById('tsa-key-input').value.trim();
if (key.length < 10) return;
apiKey = key;
_gmSet('tornStockAdvisor_apiKey', key);
showStyleSelection(true);
});
}
let refreshIntervalSet = false;
function showStyleSelection(isOnboarding) {
const body = document.getElementById('tsa-body');
const styles = Object.entries(STYLE_PROFILES);
const icons = { daytrader: 'trending-up', balanced: 'chart', longterm: 'lock' };
const colors = { daytrader: '#ff9800', balanced: '#4caf50', longterm: '#2196f3' };
body.innerHTML = `
<div style="padding:16px;text-align:center">
<p style="color:#ccc;margin:0 0 4px;font-size:14px;font-weight:600">
${isOnboarding ? 'Almost there! ' : ''}Choose your investing style
</p>
<p style="color:#666;margin:0 0 12px;font-size:11px">
This controls which signals you see. You can change it anytime in Settings.
</p>
${styles.map(([key, profile]) => `
<div class="tsa-style-card${investorStyle === key ? ' selected' : ''}" data-style="${key}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
${icon(icons[key], colors[key], 18)}
<strong style="color:#fff;font-size:13px">${profile.label}</strong>
</div>
<p style="color:#999;font-size:11px;margin:0;line-height:1.4">
${profile.description}
</p>
</div>
`).join('')}
</div>
`;
body.querySelectorAll('[data-style]').forEach(card => {
card.addEventListener('click', async () => {
investorStyle = card.dataset.style;
await _gmSet('tornStockAdvisor_investorStyle', investorStyle);
if (isOnboarding) {
await refresh();
if (!refreshIntervalSet) {
setInterval(refresh, CACHE_TTL);
refreshIntervalSet = true;
}
} else {
if (cachedAnalyses.length > 0) {
cachedRecs = generateRecommendations(cachedAnalyses);
}
const activeTab = document.querySelector('#tsa-tabs button.active')?.dataset?.tab || 'signals';
renderTab(activeTab);
const status = document.getElementById('tsa-status');
const now = new Date();
const styleName = getStyleProfile().label;
status.textContent = `Updated ${now.toLocaleTimeString()} \u00b7 ${styleName} \u00b7 ${cachedAnalyses.length} stocks \u00b7 ${Object.keys(portfolioData || {}).length} held`;
}
});
});
}
let cachedAnalyses = [];
let cachedRecs = [];
async function refresh() {
const status = document.getElementById('tsa-status');
try {
status.textContent = 'Fetching data...';
await fetchAll();
status.textContent = 'Loading price history...';
await fetchTornsyData();
cachedAnalyses = [];
for (const [id, stock] of Object.entries(marketData)) {
cachedAnalyses.push(analyzeStock(id, stock));
}
cachedRecs = generateRecommendations(cachedAnalyses);
const activeTab = document.querySelector('#tsa-tabs button.active')?.dataset?.tab || 'signals';
renderTab(activeTab);
const now = new Date();
const styleName = getStyleProfile().label;
status.textContent = `Updated ${now.toLocaleTimeString()} · ${styleName} · ${cachedAnalyses.length} stocks · ${Object.keys(portfolioData).length} held`;
} catch (err) {
if (err.message.includes('API error 2') || err.message.includes('API error 10')) {
_gmSet('tornStockAdvisor_apiKey', '');
apiKey = '';
showSetup();
status.textContent = 'Invalid API key — please re-enter';
} else {
status.textContent = `Error: ${err.message}`;
}
}
}
function renderTab(tab) {
const body = document.getElementById('tsa-body');
if (tab === 'signals') renderSignals(body);
else if (tab === 'portfolio') renderPortfolio(body);
else if (tab === 'market') renderMarket(body);
}
// ── SIGNAL GLOSSARY ─────────────────────────────────────
// Plain-language explanation for every action badge. Shown as a hover
// tooltip on the badge, and in the Signal Guide modal (tap any badge or
// Settings → Signal Guide). Written for players with zero stock-market
// background — say what to DO, not what the indicator is called.
const ACTION_INFO = {
'CLAIM': { color: '#e6b800', desc: 'A payout is ready — open the stock on Torn and collect it. Free money.' },
'BUY': { color: '#4caf50', desc: 'Long-term: buy enough shares to unlock the perk, then keep holding them. You earn from the perk, not from price changes.' },
'TOP UP': { color: '#4caf50', desc: 'You already own part of a benefit block — buy the missing shares to unlock the perk.' },
'STACK UP': { color: '#4caf50', desc: 'This perk stacks: you already have it, and buying another block makes it stronger. Each extra block costs twice the last one but pays the same amount, so the return on every new block is half the one before it — the card shows that lower figure, not what your first block earns.' },
'BUY LOW': { color: '#9c27b0', desc: 'Short-term trade: the price dropped and looks like it is recovering. Buy now, then sell once it climbs back up. You are NOT holding this one for a perk.' },
'MAYBE BUY': { color: '#78909c', desc: 'Decent long-term perk, but not a must-have. Buy only if you have spare cash.' },
'SAVE TOWARD': { color: '#78909c', desc: 'Worth owning eventually, but it costs more than you have right now — save up for it.' },
'REBALANCE': { color: '#ff6f00', desc: 'Sell the listed stocks and use the money to unlock a better perk elsewhere.' },
'SELL': { color: '#f44336', desc: 'Sell all your shares of this stock.' },
'SELL EXTRA': { color: '#4caf50', desc: 'Sell only the spare shares — you keep enough for the perk, it stays unlocked.' },
'SELL NOW': { color: '#ff9800', desc: 'The price looks like it is peaking — selling now protects your profit before it drops.' },
'SELL SOON': { color: '#ff9800', desc: 'No rush, but be ready to sell — the price may fall back from here.' },
'TOP UP / SELL': { color: '#ff9800', desc: 'Your call: finish the benefit block when you can afford it, or sell for profit and put the money elsewhere.' },
'KEEP': { color: '#2196f3', desc: 'Hold your shares — the perk you are getting makes them worth keeping.' },
'HOLD & SAVE': { color: '#78909c', desc: 'Do nothing for now — keep the shares and save cash toward finishing the benefit block.' },
'REVIEW LOSS': { color: '#ff9800', desc: 'You are losing money here with no perk to show for it — decide whether to cut losses or keep holding.' },
'WATCH': { color: '#78909c', desc: 'No action needed — just keep an eye on this one, its run may be ending.' },
};
const GLOSSARY_GROUPS = [
{ label: 'Buying', actions: ['BUY', 'TOP UP', 'STACK UP', 'BUY LOW', 'MAYBE BUY', 'SAVE TOWARD', 'REBALANCE'] },
{ label: 'Selling', actions: ['SELL', 'SELL EXTRA', 'SELL NOW', 'SELL SOON', 'TOP UP / SELL'] },
{ label: 'Holding & other', actions: ['KEEP', 'HOLD & SAVE', 'REVIEW LOSS', 'WATCH', 'CLAIM'] },
];
function openGlossary(highlightAction) {
const prev = document.getElementById('tsa-glossary-overlay');
if (prev) prev.remove();
const overlay = document.createElement('div');
overlay.id = 'tsa-glossary-overlay';
overlay.innerHTML = `
<div id="tsa-glossary-modal" role="dialog" aria-label="Signal guide">
<div class="tsa-calc-head">
<div class="tsa-calc-title">Signal guide</div>
<button type="button" class="tsa-calc-close" data-glossary-close aria-label="Close">×</button>
</div>
<p style="color:#888;font-size:11px;margin:4px 0 6px;line-height:1.5">
What each badge asks you to do. Cards are sorted by importance — the top one matters most.
</p>
${GLOSSARY_GROUPS.map(g => `
<div class="tsa-glossary-group">${g.label}</div>
${g.actions.map(action => {
const info = ACTION_INFO[action];
if (!info) return '';
return `<div class="tsa-glossary-row${action === highlightAction ? ' highlight' : ''}">
<span class="tsa-badge" style="background:${info.color}22;color:${info.color};border:1px solid ${info.color}44">${action}</span>
<span style="flex:1;line-height:1.45;color:#aaa">${info.desc}</span>
</div>`;
}).join('')}
`).join('')}
</div>
`;
document.body.appendChild(overlay);
const escClose = (e) => { if (e.key === 'Escape') cleanup(); };
const cleanup = () => {
overlay.remove();
document.removeEventListener('keydown', escClose);
};
overlay.addEventListener('click', (e) => {
if (e.target === overlay || e.target.closest('[data-glossary-close]')) cleanup();
});
document.addEventListener('keydown', escClose);
const hl = overlay.querySelector('.tsa-glossary-row.highlight');
if (hl && typeof hl.scrollIntoView === 'function') {
try { hl.scrollIntoView({ block: 'center' }); } catch { /* ignore */ }
}
}
// Share calculator — converts dollar amounts to share counts (and vice
// versa) for any stock so users don't have to do the math manually before
// placing a buy/sell on Torn. Buy fee is fee-free on Torn; sell fee is
// 0.1% (SELL_FEE) and is reflected in the "you'll receive" line.
function openShareCalc(stockId) {
const a = cachedAnalyses.find(x => Number(x.id) === Number(stockId));
if (!a || !a.price) return;
// Tear down any previous modal so re-opening on a different stock is clean.
const prev = document.getElementById('tsa-calc-overlay');
if (prev) prev.remove();
const price = a.price;
const held = a.position ? a.position.totalShares : 0;
const overlay = document.createElement('div');
overlay.id = 'tsa-calc-overlay';
overlay.innerHTML = `
<div id="tsa-calc-modal" role="dialog" aria-label="Share calculator">
<div class="tsa-calc-head">
<div class="tsa-calc-title">${a.acronym}<small>$${fmtMoney(price)}/share</small></div>
<button type="button" class="tsa-calc-close" data-calc-close aria-label="Close">×</button>
</div>
<div class="tsa-calc-row">
<label for="tsa-calc-dollars">Dollars</label>
<span class="tsa-calc-prefix">$</span>
<input type="text" inputmode="decimal" id="tsa-calc-dollars" placeholder="500,000" autocomplete="off" />
</div>
<div class="tsa-calc-row">
<label for="tsa-calc-shares">Shares</label>
<input type="text" inputmode="numeric" id="tsa-calc-shares" placeholder="0" autocomplete="off" />
</div>
<div class="tsa-calc-quick">
<button type="button" data-calc-quick="100000">$100K</button>
<button type="button" data-calc-quick="500000">$500K</button>
<button type="button" data-calc-quick="1000000">$1M</button>
<button type="button" data-calc-quick="10000000">$10M</button>
${held > 0 ? `<button type="button" data-calc-quick-shares="${held}">All held</button>` : ''}
</div>
<div class="tsa-calc-meta" id="tsa-calc-meta">${held > 0 ? `Held: ${fmtNum(held)} shares · ` : ''}Sell fee: 0.1%</div>
</div>
`;
document.body.appendChild(overlay);
const dollarsInput = overlay.querySelector('#tsa-calc-dollars');
const sharesInput = overlay.querySelector('#tsa-calc-shares');
const meta = overlay.querySelector('#tsa-calc-meta');
const parseNum = (s) => {
const n = parseFloat(String(s).replace(/[^0-9.]/g, ''));
return Number.isFinite(n) ? n : 0;
};
let updating = false;
const updateMeta = (shares) => {
const gross = shares * price;
const sellNet = gross * (1 - SELL_FEE);
const fee = gross - sellNet;
const heldStr = held > 0 ? `Held: ${fmtNum(held)} · ` : '';
if (shares > 0) {
meta.innerHTML = `${heldStr}<strong style="color:#ccc">${fmtNum(shares)}</strong> shares = $${fmtMoney(gross)} cost / $${fmtMoney(sellNet)} after 0.1% sell fee ($${fmtMoney(fee)})`;
} else {
meta.textContent = `${held > 0 ? `Held: ${fmtNum(held)} shares · ` : ''}Sell fee: 0.1%`;
}
};
const fromDollars = () => {
if (updating) return;
updating = true;
const d = parseNum(dollarsInput.value);
const shares = price > 0 ? Math.floor(d / price) : 0;
sharesInput.value = shares > 0 ? fmtNum(shares) : '';
updateMeta(shares);
updating = false;
};
const fromShares = () => {
if (updating) return;
updating = true;
const s = Math.floor(parseNum(sharesInput.value));
const d = s * price;
dollarsInput.value = d > 0 ? fmtNum(Math.round(d)) : '';
updateMeta(s);
updating = false;
};
const escClose = (e) => { if (e.key === 'Escape') cleanup(); };
const cleanup = () => {
overlay.remove();
document.removeEventListener('keydown', escClose);
};
dollarsInput.addEventListener('input', fromDollars);
sharesInput.addEventListener('input', fromShares);
overlay.addEventListener('click', (e) => {
if (e.target === overlay || e.target.closest('[data-calc-close]')) {
cleanup();
return;
}
const quick$ = e.target.closest('[data-calc-quick]');
if (quick$) {
dollarsInput.value = fmtNum(Number(quick$.dataset.calcQuick));
fromDollars();
return;
}
const quickS = e.target.closest('[data-calc-quick-shares]');
if (quickS) {
sharesInput.value = fmtNum(Number(quickS.dataset.calcQuickShares));
fromShares();
return;
}
});
document.addEventListener('keydown', escClose);
setTimeout(() => dollarsInput.focus(), 50);
}
function supplyTag(availPct) {
if (availPct < 1) return '<span class="tsa-tag neutral">Almost sold out</span>';
if (availPct < 5) return '<span class="tsa-tag neutral">Few shares left</span>';
return '';
}
function renderSignals(body) {
// Muted footer notes explaining every suppression, so a short (or empty)
// Signals tab is never a mystery — the user can see whether it's a quiet
// market, their budget, their profile, or missing price data.
const sp = getStyleProfile();
const notes = [];
if (buyingPowerHiddenCount > 0) {
notes.push(`${buyingPowerHiddenCount} benefit block${buyingPowerHiddenCount > 1 ? 's' : ''} above your ~$${fmtMoney(userCash.immediate)} buying power ${buyingPowerHiddenCount > 1 ? 'are' : 'is'} hidden.`);
}
// The bank-bonus setting changed scale in 1.17.1 and the old value had to
// be dropped. Say so — otherwise every bank-vs-benefit comparison quietly
// runs on base rates and the user has no idea their bonus stopped counting.
if (bankBonusNeedsReview) {
notes.push('Your bank interest bonus needs re-entering: the setting changed from percentage points to a percentage of the base rate (how Torn actually applies merits and TCB). Set it in Settings → Bank APR Bonus.');
}
if (profileHiddenBuyCount > 0) {
notes.push(`${profileHiddenBuyCount} mid-return benefit block${profileHiddenBuyCount > 1 ? 's' : ''} hidden by the ${esc(sp.label)} profile (it skips slow-burn perk buys). Switch profile in Settings to see them.`);
}
// Momentum signals (BUY LOW / SELL NOW / SELL SOON / WATCH) all need
// Tornsy candles. If they're missing market-wide, say so — otherwise the
// tab just looks empty for no reason.
if (noTechnicalsCount > 0 && cachedAnalyses.length > 0 && noTechnicalsCount === cachedAnalyses.length) {
notes.push('Price history is unavailable right now (tornsy.com), so momentum signals like BUY LOW are paused. They return once history loads.');
}
// No BUY LOW today? Name the stocks that came closest and what each is
// still missing. A flat market and a broken signal look identical from an
// empty tab; this is the difference.
if (sp.buyDipEnabled && !cachedRecs.some(r => r.action === 'BUY LOW') && buyLowNearMisses.length > 0) {
const closest = buyLowNearMisses.slice(0, 3)
.map(m => `<b>${esc(m.acronym)}</b> — ${esc(m.blocker)}`)
.join('<br>');
notes.push(`No BUY LOW right now. Closest ${buyLowNearMisses.length > 1 ? 'candidates' : 'candidate'}:<br>${closest}`);
}
// "Everything says KEEP" — the reported symptom of owning a full block of
// most stocks. Passive perks are binary, so those stocks really have
// nothing left to buy; say it rather than leaving the buy side silent.
const BUY_FAMILY = new Set(['BUY', 'TOP UP', 'STACK UP', 'BUY LOW', 'MAYBE BUY', 'SAVE TOWARD', 'REBALANCE']);
if (maxedPassiveCount > 0 && !cachedRecs.some(r => BUY_FAMILY.has(r.action))) {
notes.push(`${maxedPassiveCount} of your holdings already ${maxedPassiveCount > 1 ? 'have' : 'has'} a complete benefit block. Those perks don't stack — extra shares there earn nothing — so KEEP is the whole story for them. Spare cash is better off in the bank or saved toward a stock you don't own yet.`);
}
const hiddenHint = notes.length
? `<div class="tsa-empty" style="padding:8px 12px;font-size:11px;color:#888;border-top:1px solid #2a2a2a;text-align:left">${notes.join('<br>')}</div>`
: '';
if (cachedRecs.length === 0) {
body.innerHTML = '<div class="tsa-empty">Nothing to do right now — no buying or selling opportunities detected.</div>' + hiddenHint;
return;
}
body.innerHTML = cachedRecs.map(r => `
<div class="tsa-card tsa-clickable" data-find="${r.stock.acronym}" title="Tap to filter Torn's stock list to ${r.stock.acronym}">
<span class="tsa-badge" data-glossary="${esc(r.action)}" title="${esc(ACTION_INFO[r.action]?.desc || '')} (tap for signal guide)" style="background:${r.color}22;color:${r.color};border:1px solid ${r.color}44">${r.action}</span>
<div class="tsa-card-body">
<div>
<span class="tsa-stock-name">${r.stock.acronym}</span>
<span class="tsa-stock-price">$${fmtMoney(r.stock.price)}</span>
</div>
<div class="tsa-reason">${r.reason}</div>
<div class="tsa-meta">
${r.stock.technicals ? `<span class="tsa-tag" style="background:${r.stock.technicals.signalColor}22;color:${r.stock.technicals.signalColor}">${r.stock.technicals.signal}</span>` : ''}
${r.stock.technicals?.rsi ? `<span class="tsa-tag ${r.stock.technicals.rsi < 30 ? 'good' : r.stock.technicals.rsi > 70 ? 'bad' : ''}">${r.stock.technicals.rsi < 30 ? 'Cheap' : r.stock.technicals.rsi > 70 ? 'Expensive' : 'Fair price'}</span>` : ''}
${r.stock.technicals?.change7d !== null && r.stock.technicals?.change7d !== undefined ? `<span class="tsa-tag ${r.stock.technicals.change7d > 0 ? 'good' : r.stock.technicals.change7d < -1 ? 'bad' : ''}">${r.stock.technicals.change7d >= 0 ? '+' : ''}${r.stock.technicals.change7d.toFixed(1)}% this week</span>` : ''}
${supplyTag(r.stock.availPct)}
</div>
${r._rebalanceDetail ? `
<div class="tsa-rebalance-detail">
Sell: ${r._rebalanceDetail.sellPlan.map(s =>
`${s.analysis.acronym} (${fmtNum(s.shares)} shares, $${fmtMoney(s.value)})`
).join(' + ')}
${r._rebalanceDetail.breakEvenMonths !== null ? ` · Break-even: ~${Math.ceil(r._rebalanceDetail.breakEvenMonths)} months` : ''}
</div>
` : ''}
</div>
</div>
`).join('') + hiddenHint;
}
function renderPortfolio(body) {
const held = cachedAnalyses.filter(a => a.position);
if (held.length === 0) {
body.innerHTML = '<div class="tsa-empty">No stocks in portfolio.</div>';
return;
}
let totalValue = 0;
let totalCost = 0;
held.forEach(a => {
totalValue += a.position.currentValue;
totalCost += a.position.costBasis;
});
const totalPnl = totalValue - totalCost;
const totalPnlPct = totalCost > 0 ? (totalPnl / totalCost) * 100 : 0;
const summaryClass = totalPnl >= 0 ? 'tsa-pnl-pos' : 'tsa-pnl-neg';
body.innerHTML = `
<div style="padding:8px 12px;border-bottom:1px solid #333;display:flex;justify-content:space-between;font-size:12px">
<span style="color:#888">Total: <strong style="color:#fff">$${fmtMoney(totalValue)}</strong></span>
<span class="${summaryClass}">${totalPnl >= 0 ? '+' : '-'}$${fmtMoney(Math.abs(totalPnl))} (${totalPnlPct >= 0 ? '+' : ''}${totalPnlPct.toFixed(2)}%)</span>
</div>
<div class="tsa-portfolio-header">
<div title="Click lock to protect stock from sell signals"></div>
<div>Stock</div>
<div>Shares / Benefit</div>
<div>Value</div>
<div>P/L</div>
<div>%</div>
</div>
${held.sort((a, b) => b.position.currentValue - a.position.currentValue).map(a => {
const p = a.position;
const pnlClass = p.pnlPct >= 0 ? 'tsa-pnl-pos' : 'tsa-pnl-neg';
const bbIcon = p.bbCount > 1
? `${p.bbCount}×${icon('check', '#4caf50', 14)}`
: p.hasBenefit ? icon('check', '#4caf50', 14) : (a.benefit ? icon('square', '#555', 14) : '—');
const extraLabel = p.sharesAboveBB > 0 ? ` <span style="color:#78909c;font-size:10px">+${fmtNum(p.sharesAboveBB)}</span>` : '';
const bbTooltip = a.benefit && a.benefit.requirement
? (a.benefit.type === 'active'
? `${p.bbCount} increment${p.bbCount !== 1 ? 's' : ''} (each successive increment costs 2× the previous)${p.sharesAboveBB > 0 ? `, ${fmtNum(p.sharesAboveBB)} toward next` : ''}`
: `Benefit ${p.bbCount ? 'active' : 'inactive'} (${fmtNum(a.benefit.requirement)} shares required)${p.sharesAboveBB > 0 ? `, ${fmtNum(p.sharesAboveBB)} extra` : ''}`)
: '';
const divIcon = p.dividendReady ? ' ' + icon('bell', '#e6b800', 14) : '';
const isLocked = lockedStocks.includes(Number(a.id));
const lockIcon = isLocked ? icon('lock', '#e6b800', 14) : icon('lock-open', '#555', 14);
const topSignal = cachedRecs.find(r => r.stock.id === a.id);
const signalBadge = topSignal
? `<br><span class="tsa-portfolio-signal" data-glossary="${esc(topSignal.action)}" title="${esc(ACTION_INFO[topSignal.action]?.desc || '')} (tap for signal guide)" style="background:${topSignal.color}22;color:${topSignal.color}">${topSignal.action}</span>`
: '';
return `<div class="tsa-portfolio-row tsa-clickable" data-calc="${a.id}" title="Tap for $ ↔ shares calculator">
<span class="tsa-lock-btn" data-lock="${a.id}" title="${isLocked ? 'Locked — protected from sell signals (click to unlock)' : 'Unlocked — click to protect from sell signals'}">${lockIcon}</span>
<span class="tsa-stock-name">${a.acronym}${divIcon}${signalBadge}</span>
<span${bbTooltip ? ` title="${bbTooltip}"` : ''}>${fmtNum(p.totalShares)} ${bbIcon}${extraLabel} ${p.dividendProgress || ''}</span>
<span>$${fmtMoney(p.currentValue)}</span>
<span class="${pnlClass}">${p.pnl >= 0 ? '+' : '-'}$${fmtMoney(Math.abs(p.pnl))}</span>
<span class="${pnlClass}">${p.pnlPct >= 0 ? '+' : ''}${p.pnlPct.toFixed(2)}%</span>
</div>`;
}).join('')}
`;
}
function renderMarket(body) {
const sorted = [...cachedAnalyses].sort((a, b) => {
// Sort by momentum score first if technicals available, then ROI
const ma = a.technicals?.momentum || 0;
const mb = b.technicals?.momentum || 0;
if (Math.abs(mb - ma) > 0.5) return mb - ma;
const tierOrder = { S: 3, A: 2 };
const ta = tierOrder[a.benefitROI?.tier] || 0;
const tb = tierOrder[b.benefitROI?.tier] || 0;
if (ta !== tb) return tb - ta;
return b.annualROI - a.annualROI;
});
const mktCols = '50px 65px 65px 50px 55px minmax(80px, auto)';
body.innerHTML = `
<div class="tsa-market-scroll">
<div class="tsa-market-inner">
<div class="tsa-portfolio-header" style="grid-template-columns: ${mktCols}">
<div>Stock</div>
<div>Price</div>
<div>Trend</div>
<div>RSI <span title="Relative Strength Index (0–100), calculated from ${getStyleProfile().rsiPeriod}h of hourly candles (Tornsy data). Below 30 (green) = oversold — price dropped a lot recently, may be a buying opportunity. Above 70 (red) = overbought — price rose a lot recently, may be due for a pullback. 30–70 (gray) = neutral. The lookback period depends on your investor profile (${getStyleProfile().label})." style="cursor:help;opacity:.5;font-size:10px">?</span></div>
<div>7d</div>
<div>Benefit</div>
</div>
${sorted.map(a => {
const held = a.position ? '•' : '';
const t = a.technicals;
const signalStr = t
? `<span style="color:${t.signalColor};font-size:11px;font-weight:600">${t.signal.replace('Slightly ', '~')}</span>`
: '<span style="color:#555">—</span>';
const rsiStr = t?.rsi != null
? `<span style="color:${t.rsi < 30 ? '#4caf50' : t.rsi > 70 ? '#f44336' : '#888'}">${Math.round(t.rsi)}</span>`
: '—';
const change7dStr = t?.change7d !== null && t?.change7d !== undefined
? `<span class="${t.change7d > 0 ? 'tsa-pnl-pos' : t.change7d < -1 ? 'tsa-pnl-neg' : ''}">${t.change7d >= 0 ? '+' : ''}${t.change7d.toFixed(1)}%</span>`
: '—';
const bbInfo = a.annualROI > 0
? (a.benefitROI?.bankBeatsBB
? (a.benefitROI.bankActionable
? `${a.annualROI.toFixed(0)}%/yr (bank ${a.benefitROI.bankTermLabel}: ${a.benefitROI.bankApr.toFixed(0)}% ✗)`
: `${a.annualROI.toFixed(0)}%/yr (bank ${a.benefitROI.bankTermLabel}: ${a.benefitROI.bankApr.toFixed(0)}% — locked)`)
: `${a.annualROI.toFixed(0)}%/yr`)
: a.benefitROI?.tier === 'S'
? `Top tier`
: a.benefitROI?.tier === 'A'
? `Great`
: a.bbCost ? `$${fmtMoney(a.bbCost)}` : '—';
return `<div class="tsa-portfolio-row tsa-clickable" data-calc="${a.id}" style="grid-template-columns: ${mktCols}" title="Tap for $ ↔ shares calculator">
<span class="tsa-stock-name">${held}${a.acronym}</span>
<span>$${fmtMoney(a.price)}</span>
<span>${signalStr}</span>
<span style="font-size:11px">${rsiStr}</span>
<span style="font-size:11px">${change7dStr}</span>
<span style="font-size:10px;color:#888;white-space:nowrap">${bbInfo}</span>
</div>`;
}).join('')}
</div>
</div>
`;
}
// ── TAMPERMONKEY MENU COMMANDS (desktop only) ──────────
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('🔑 Set API Key', () => {
const key = prompt('Enter your Torn API key:\n\nMinimum required: Limited Access\n(Settings → API Keys → select Limited or higher)');
if (key && key.trim().length >= 10) {
apiKey = key.trim();
_gmSet('tornStockAdvisor_apiKey', apiKey);
lastFetch = 0;
if (document.getElementById('tsa-panel')) refresh();
alert('API key saved! Refresh the stock page to apply.');
} else if (key !== null) {
alert('Invalid key — must be at least 10 characters.');
}
});
GM_registerMenuCommand('🗑️ Clear API Key', () => {
if (confirm('Remove your stored API key?')) {
apiKey = '';
_gmSet('tornStockAdvisor_apiKey', '');
if (document.getElementById('tsa-panel')) showSetup();
alert('API key cleared.');
}
});
GM_registerMenuCommand('ℹ️ About Stock Advisor', () => {
alert(
'Torn Stock Advisor v' + TSA_VERSION + '\n\n' +
'• Investor style: ' + getStyleProfile().label + '\n' +
'• No automated trading — display only\n\n' +
'API USAGE\n' +
'• Access: Limited (read-only; no trading, no writes)\n' +
'• Key storage: Locally in your userscript manager only\n' +
'• Key shared with: Nobody\n' +
'• API data used: Stocks, money, bank rates\n' +
'• Torn API calls: 3 per refresh (60s interval), to api.torn.com only\n' +
'• External service: Tornsy is used for public stock price history\n' +
' (refreshed every 10 min); your Torn API key is never sent to Tornsy.'
);
});
}
// ── TORN PAGE: STOCK SEARCH BAR + ROW FILTER ────────────
// Injects a small search input directly above Torn's stock list inside
// #stockmarketroot so users (especially on TornPDA) can find a stock fast.
// Filtering hides non-matching <ul class="stock___CnywB"> rows. The same
// input is driven programmatically by `applyTornStockSearch` so clicking a
// signal card in our panel ("Find SYS") filters the list to that stock.
let _tornStockSearchQuery = '';
function _getStockAcronymFromUl(stockUl) {
const img = stockUl.querySelector('img[src*="/logos/"]');
if (img) {
const m = (img.getAttribute('src') || '').match(/logos\/([A-Z]{2,5})\.svg/i);
if (m) return m[1].toUpperCase();
}
const acrEl = stockUl.querySelector('[data-acronym]');
if (acrEl) return (acrEl.getAttribute('data-acronym') || '').toUpperCase();
return '';
}
function _getStockNameFromUl(stockUl) {
const nameEl = stockUl.querySelector('[aria-label^="Stock:"]');
if (!nameEl) return '';
const m = (nameEl.getAttribute('aria-label') || '').match(/^Stock:\s*(.+)$/);
return m ? m[1] : '';
}
function _filterTornStockRows() {
const q = _tornStockSearchQuery.trim().toUpperCase();
const rows = document.querySelectorAll('ul.stock___CnywB, ul[class*="stock___"]');
let firstMatch = null;
rows.forEach(row => {
const acronym = _getStockAcronymFromUl(row);
const name = _getStockNameFromUl(row).toUpperCase();
const matches = !q || acronym.includes(q) || name.includes(q);
row.style.display = matches ? '' : 'none';
if (matches && !firstMatch) firstMatch = row;
});
return firstMatch;
}
function applyTornStockSearch(query, opts = {}) {
_tornStockSearchQuery = String(query || '');
const input = document.getElementById('tsa-page-search-input');
if (input && input.value !== _tornStockSearchQuery) input.value = _tornStockSearchQuery;
const wrap = document.getElementById('tsa-page-search');
if (wrap) wrap.classList.toggle('active', _tornStockSearchQuery.length > 0);
const firstMatch = _filterTornStockRows();
if (opts.scroll && firstMatch && typeof firstMatch.scrollIntoView === 'function') {
try { firstMatch.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* ignore */ }
}
}
function injectTornStockSearchBar() {
const root = document.getElementById('stockmarketroot');
if (!root || document.getElementById('tsa-page-search')) return;
// Place above the stock list (just before .stockMarket___...). Fall back
// to appending into root if we can't find the list container.
const listContainer = root.querySelector('[class*="stockMarket___"]')
|| root.querySelector('ul.stock___CnywB')?.parentElement;
const bar = document.createElement('div');
bar.id = 'tsa-page-search';
bar.innerHTML = `
<input id="tsa-page-search-input" type="search" placeholder="Find stock (SYS, WLT, name…)" autocomplete="off" />
<button id="tsa-page-search-clear" title="Clear" aria-label="Clear">×</button>
`;
if (listContainer && listContainer.parentNode) {
listContainer.parentNode.insertBefore(bar, listContainer);
} else {
root.appendChild(bar);
}
const input = bar.querySelector('#tsa-page-search-input');
const clearBtn = bar.querySelector('#tsa-page-search-clear');
input.addEventListener('input', () => applyTornStockSearch(input.value));
clearBtn.addEventListener('click', () => { applyTornStockSearch(''); input.focus(); });
// Pre-populate from any standing query (e.g. user clicked "Find SYS" before
// the bar mounted on a slow render).
if (_tornStockSearchQuery) {
input.value = _tornStockSearchQuery;
bar.classList.add('active');
_filterTornStockRows();
}
}
// ── TORN PAGE: $ MONEY INPUT ON BUY / SELL FORMS ────────
// When the user opens a stock's buy or sell action area, Torn renders a
// shares input + BUY/SELL button. We inject a small "$ money input" row
// underneath it: users type a dollar amount (with k/m/b shortcuts and
// commas) and we set Torn's shares input via the React-aware value setter.
function _getStockPrice(stockUl) {
const priceEl = stockUl.querySelector('[aria-label^="Share stock price"]');
if (priceEl) {
const m = (priceEl.getAttribute('aria-label') || '').match(/\$([\d,]+\.?\d*)/);
if (m) return parseFloat(m[1].replace(/,/g, ''));
}
// Fallback: read the digit spans inside .price___...
const digits = Array.from(stockUl.querySelectorAll('.price___WsxqW span, [class*="price___"] span'))
.map(s => s.textContent || '').join('');
const n = parseFloat(digits.replace(/[^0-9.]/g, ''));
return Number.isFinite(n) ? n : 0;
}
// React patches the direct prototype's value setter, so assigning .value
// (or even using the direct prototype's setter) is invisible to React's
// onChange. We must call the NATIVE HTMLInputElement setter, then bubble an
// input event. We also clear React's internal _valueTracker so the new
// value isn't deduped against the old. This is the same pattern used by
// react-testing-library / Cypress.
function _setReactInputValue(el, value) {
try {
if (el._valueTracker && typeof el._valueTracker.setValue === 'function') {
// Setting to '' first guarantees React sees a change vs prior value.
el._valueTracker.setValue('');
}
} catch { /* ignore */ }
try {
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
nativeSetter.call(el, value);
} catch {
el.value = value;
}
// Fire the events Torn / React listeners commonly use. Some controlled
// components only commit on `change` (blur); others read `input`. We fire
// both, plus a keyup so any keystroke-based listeners pick it up too.
try { el.dispatchEvent(new InputEvent('input', { bubbles: true, data: value, inputType: 'insertText' })); }
catch { el.dispatchEvent(new Event('input', { bubbles: true })); }
el.dispatchEvent(new Event('change', { bubbles: true }));
try { el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0' })); } catch { /* ignore */ }
}
// Read the per-share price from a buy or sell block's own message text.
// Buy: "$6,792,160 will buy you 38,715 shares" → 6792160 / 38715
// Sell: "Your 440,000 shares are worth $34,095,600" → 34095600 / 440000
// This is the only reliable way to know the price for THIS panel — Torn
// renders the buy/sell panel as a sibling of the stock UL, so walking up
// the DOM eventually reaches an ancestor containing every stock's logo and
// picks the wrong stock's price.
function _priceFromBlockMessage(blockEl) {
if (!blockEl) return 0;
const msg = blockEl.querySelector('[class*="message___"]') || blockEl;
const text = (msg.textContent || '').replace(/\s+/g, ' ');
let m = text.match(/\$([\d,]+(?:\.\d+)?)\s*will buy you\s*([\d,]+)/i);
if (m) {
const dollars = parseFloat(m[1].replace(/,/g, ''));
const shares = parseFloat(m[2].replace(/,/g, ''));
if (shares > 0) return dollars / shares;
}
m = text.match(/Your\s*([\d,]+)\s*shares\s*are worth\s*\$([\d,]+(?:\.\d+)?)/i);
if (m) {
const shares = parseFloat(m[1].replace(/,/g, ''));
const dollars = parseFloat(m[2].replace(/,/g, ''));
if (shares > 0) return dollars / shares;
}
return 0;
}
// Resolve the price for a given BUY/SELL button. The button's own
// buy/sell block message is authoritative — no DOM-walking required.
function _resolveStockContext(fromEl) {
// Each block has its own price message — buy uses the buy block, sell
// uses the sell block. Both live inside a manageBlock___ wrapper.
const block = fromEl.closest('[class*="buyBlock___"], [class*="sellBlock___"], [class*="manageBlock___"]');
if (block) {
const price = _priceFromBlockMessage(block);
if (price) return { price, acronym: '', scope: block };
}
// Fallback: panel's preceding stock UL sibling.
const panel = fromEl.closest('[class*="manageTab___"], [class*="manageContainer___"]');
if (panel) {
let sibling = panel.previousElementSibling;
for (let i = 0; i < 5 && sibling; i++) {
if (sibling.tagName === 'UL' && /stock___/.test(sibling.className || '')) {
const price = _getStockPrice(sibling);
if (price) return { price, acronym: _getStockAcronymFromUl(sibling), scope: sibling };
}
sibling = sibling.previousElementSibling;
}
}
// Last resort: direct stock UL ancestor (only valid if Torn ever puts the
// panel INSIDE the UL).
const directUl = fromEl.closest('ul[class*="stock___"]');
if (directUl) {
const price = _getStockPrice(directUl);
if (price) return { price, acronym: _getStockAcronymFromUl(directUl), scope: directUl };
}
return null;
}
function injectMoneyInputs() {
// Search the whole page for BUY/SELL buttons. Torn renders the action
// panel as a sibling or descendant of the stock list, so scoping to the
// <ul> is too narrow.
const buttons = Array.from(document.querySelectorAll('button')).filter(b => {
const t = (b.textContent || '').trim().toUpperCase();
return (t === 'BUY' || t === 'SELL') && !b.dataset.tsaSeen;
});
// Pick the actual shares field, not Torn's "max" <input type="button">
// sibling and not hidden mirrors. Prefer Torn's known shares input
// (`input.input-money`) when present.
const isSharesInput = (el) => {
if (!el) return false;
const t = (el.getAttribute('type') || '').toLowerCase();
if (['button', 'submit', 'reset', 'checkbox', 'radio', 'hidden', 'search', 'image', 'file'].includes(t)) return false;
if (el.classList.contains('tsa-money-input')) return false;
return true;
};
const pickSharesInput = (root) => {
// Strong preference: Torn's legacy money input by class/testid.
const preferred = root.querySelector('input.input-money:not([type="hidden"]), input[data-testid="legacy-money-input"]:not([type="hidden"])');
if (preferred && isSharesInput(preferred)) return preferred;
// Fallback: first real text-like input.
const candidates = root.querySelectorAll('input');
for (const c of candidates) if (isSharesInput(c)) return c;
return null;
};
for (const btn of buttons) {
// Walk up from the button to find an input nearby (the shares field).
let container = btn.parentElement;
let input = null;
for (let i = 0; i < 6 && container; i++) {
input = pickSharesInput(container);
if (input) break;
container = container.parentElement;
}
if (!input) continue;
// Skip if our money row already sits next to this input.
if (input.dataset.tsaMoneyAugmented && document.body.contains(
input.parentElement?.parentElement?.querySelector('.tsa-money-row'))) {
continue;
}
// If a money row exists anywhere in this container, skip (avoid dupes).
if (container && container.querySelector('.tsa-money-row')) continue;
const ctx = _resolveStockContext(btn);
if (!ctx || !ctx.price) continue;
input.dataset.tsaMoneyAugmented = '1';
btn.dataset.tsaSeen = '1';
const action = (btn.textContent || '').trim().toUpperCase() === 'SELL' ? 'sell' : 'buy';
const wrap = document.createElement('div');
wrap.className = 'tsa-money-row';
wrap.innerHTML = `
<span class="tsa-money-label">$ money input:</span>
<input type="text" class="tsa-money-input" inputmode="decimal" placeholder="e.g. 1m, 500k" autocomplete="off" />
<span class="tsa-money-result" data-action="${action}">→ 0 shares</span>
`;
// Place the money row immediately after the row that contains the input.
const anchor = input.closest('div')?.parentElement || input.parentElement;
anchor.parentNode.insertBefore(wrap, anchor.nextSibling);
// Torn pins the manage container to a fixed pixel height via inline
// style — clip our injected row on mobile. Clear it so the container
// grows to fit. Defensive belt for browsers that don't support :has().
const fixedHeight = wrap.closest('[class*="manageContainer___"]');
if (fixedHeight && fixedHeight.style.height) {
fixedHeight.style.height = 'auto';
fixedHeight.style.minHeight = '210px';
}
const moneyInput = wrap.querySelector('.tsa-money-input');
const result = wrap.querySelector('.tsa-money-result');
const price = ctx.price;
// Find Torn's current shares input by walking up from our row. We can't
// capture a single reference at injection time — Torn's React re-mounts
// the input on every render, so any captured node quickly becomes
// detached and writes to it are invisible.
const findTornSharesInput = () => {
let walker = wrap.parentElement;
for (let i = 0; i < 6 && walker; i++) {
// Prefer Torn's known shares input class/testid.
const preferred = walker.querySelector('input.input-money:not([type="hidden"]), input[data-testid="legacy-money-input"]:not([type="hidden"])');
if (preferred && isSharesInput(preferred) && !preferred.closest('.tsa-money-row') && document.body.contains(preferred)) {
return preferred;
}
const candidates = walker.querySelectorAll('input');
for (const c of candidates) {
if (!isSharesInput(c)) continue;
if (c.closest('.tsa-money-row')) continue;
if (!document.body.contains(c)) continue;
return c;
}
walker = walker.parentElement;
}
return null;
};
const onInput = () => {
const dollars = parseMoneyShort(moneyInput.value);
// Reformat with thousands commas, preserving caret by digit count.
const raw = moneyInput.value;
const caret = moneyInput.selectionStart ?? raw.length;
const digitsBefore = raw.slice(0, caret).replace(/[^0-9]/g, '').length;
const formatted = dollars > 0 ? fmtNum(dollars) : '';
if (moneyInput.value !== formatted) {
moneyInput.value = formatted;
let pos = 0, seen = 0;
while (pos < formatted.length && seen < digitsBefore) {
if (/[0-9]/.test(formatted[pos])) seen++;
pos++;
}
try { moneyInput.setSelectionRange(pos, pos); } catch { /* ignore */ }
}
const shares = price > 0 ? Math.floor(dollars / price) : 0;
result.textContent = `→ ${fmtNum(shares)} shares`;
const target = findTornSharesInput();
if (target) _setReactInputValue(target, shares > 0 ? String(shares) : '');
};
moneyInput.addEventListener('input', onInput);
}
}
// ── STOCK ABBREVIATION OVERLAY ──────────────────────────
function addStockAbbreviations() {
const logoContainers = document.querySelectorAll('[class*="logoContainer___"]');
logoContainers.forEach(container => {
// Skip if already labelled
if (container.querySelector('.tsa-stock-abbr')) return;
const img = container.querySelector('img');
if (!img) return;
// Extract acronym from logo src: .../logos/WLT.svg → WLT
const src = img.getAttribute('src') || '';
const match = src.match(/logos\/([A-Z]{2,5})\.svg/i);
if (!match) return;
const acronym = match[1].toUpperCase();
const badge = document.createElement('span');
badge.className = 'tsa-stock-abbr';
badge.textContent = acronym;
container.appendChild(badge);
});
}
// ── INIT ────────────────────────────────────────────────
async function init() {
// Load stored state (async for Greasemonkey compat)
try {
apiKey = await _gmGet('tornStockAdvisor_apiKey', '');
} catch { apiKey = ''; }
// In TornPDA, auto-use the injected API key if no key is stored
if (!apiKey && IS_PDA && PDA_KEY_PLACEHOLDER !== '###' + 'PDA-APIKEY' + '###') {
apiKey = PDA_KEY_PLACEHOLDER;
}
try {
const raw = await _gmGet('tornStockAdvisor_priceHistory', '{}');
priceHistory = JSON.parse(raw);
} catch { priceHistory = {}; }
try {
const rawLocked = await _gmGet('tornStockAdvisor_lockedStocks', '[]');
lockedStocks = JSON.parse(rawLocked);
} catch { lockedStocks = []; }
try {
investorStyle = await _gmGet('tornStockAdvisor_investorStyle', '');
} catch { investorStyle = ''; }
try {
// 1.17.1 changed this setting from percentage POINTS to a percentage OF
// the base rate. The scales aren't convertible — the old prompt lumped
// merits and the TCB block into one number — so a legacy value is
// dropped and flagged for re-entry rather than quietly misapplied.
const rawRel = await _gmGet('tornStockAdvisor_bankBonusRelPct', '');
if (rawRel !== '' && rawRel != null) {
const parsedRel = Number(rawRel);
bankBonusPct = Number.isFinite(parsedRel) ? parsedRel : 0;
} else {
const legacy = Number(await _gmGet('tornStockAdvisor_bankBonusPct', '0'));
bankBonusPct = 0;
bankBonusNeedsReview = Number.isFinite(legacy) && legacy > 0;
}
} catch { bankBonusPct = 0; }
try {
const rawBank = await _gmGet('tornStockAdvisor_bankAprCache', '');
if (rawBank) {
const cached = JSON.parse(rawBank);
if (cached && typeof cached === 'object') {
bankAprTable = {
7: Number(cached[7]) || 0,
14: Number(cached[14]) || 0,
31: Number(cached[31]) || 0,
62: Number(cached[62]) || 0,
93: Number(cached[93]) || 0
};
}
}
} catch { /* keep zeros */ }
injectStyles();
// Add stock abbreviation labels on native stock logos (useful on mobile)
addStockAbbreviations();
injectTornStockSearchBar();
injectMoneyInputs();
const stockObserver = new MutationObserver(() => {
addStockAbbreviations();
injectTornStockSearchBar();
injectMoneyInputs();
if (_tornStockSearchQuery) _filterTornStockRows();
});
const stockList = document.querySelector('[class*="stockMarket___"]') || document.querySelector('#stockmarketroot') || document.body;
stockObserver.observe(stockList, { childList: true, subtree: true });
createPanel();
if (!apiKey) {
showSetup();
return;
}
if (!investorStyle) {
showStyleSelection(true);
return;
}
await refresh();
// Auto-refresh every 60s. Guard against double-binding in case init()
// runs twice (e.g., DOM ready transitions, SPA-style re-injection).
if (!refreshIntervalSet) {
setInterval(refresh, CACHE_TTL);
refreshIntervalSet = true;
}
}
// ── TEST EXPORTS (Node only) ────────────────────────────
// Lets `node --test` (run from the repo root) require this file and
// exercise the pure helpers. Userscript engines never define `module`,
// so this is inert in the browser.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
computeBenefitProgress,
nextBenefitIncrement,
marginalIncrementROI,
effectiveBankApr,
parseMoneyShort,
calcSMA,
calcRSI,
analyzeOHLC,
isBuyLowSetup,
volOverHours,
explainBuyLow,
STYLE_PROFILES,
fmtMoney,
fmtNum,
esc,
};
}
// Wait for page to be ready (no-op outside a browser, e.g. under Node tests)
if (typeof document !== 'undefined') {
if (document.readyState === 'complete') {
init();
} else {
window.addEventListener('load', init);
}
}
})();