Greasy Fork is available in English.

Torn Stock Advisor

Buy/sell recommendations for Torn City stock exchange — benefit ROI, price momentum, and portfolio P/L

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Torn Stock Advisor
// @namespace    https://torn.com/stock-advisor
// @version      1.6.6
// @description  Buy/sell recommendations for Torn City stock exchange — benefit ROI, price momentum, and portfolio P/L
// @author       Marcin
// @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
// ==/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 ──────────────────────────────────────────────
  const CACHE_TTL = 60_000; // 60s between API refreshes
  const SELL_FEE = 0.001;   // 0.1% sell fee
  // 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 Clothing', 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: 'Prize Takers Sect.', 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: 0, note: 'Clothing benefits (always on)' },
    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: 'Casino perks (always on)' },
    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)' },
    32: { 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
  };
  // ── TORNPDA DETECTION ───────────────────────────────────
  const IS_PDA = typeof PDA_httpGet === 'function' || !!window.flutter_inappwebview;
  const PDA_KEY_PLACEHOLDER = '###PDA-APIKEY###';
  // ── STATE ───────────────────────────────────────────────
  // State vars — loaded asynchronously in init() for Greasemonkey compat
  let apiKey = '';
  let lastFetch = 0;
  let marketData = null;
  let portfolioData = null;
  // 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 price changed or > 30s since last entry
      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));
  }
  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 ((newest.price - oldest.price) / oldest.price) * 100;
  }
  // ── 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;
    }
    if (losses === 0) return 100;
    const rs = (gains / period) / (losses / period);
    return 100 - (100 / (1 + rs));
  }
  function analyzeOHLC(candles) {
    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
    const rsi = calcRSI(closes);
    // Price vs SMAs
    const aboveSMA7 = sma7 && current > sma7;
    const aboveSMA20 = sma20 && current > sma20;
    // 7-day and 30-day price change
    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;
    // 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) {
      if (change7d > 3) momentum += 0.5;
      else if (change7d < -3) 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,
      change7d, change30d, rangePos,
      momentum, signal, signalColor,
      aboveSMA7, aboveSMA20
    };
  }
  // 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) => {
        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] = await Promise.all([
      tornFetch('torn/?selections=stocks'),
      tornFetch('user/?selections=stocks')
    ]);
    marketData = market.stocks;
    portfolioData = portfolio.stocks || {};
    lastFetch = Date.now();
    // Record price snapshot for local history tracking
    recordPrices(marketData);
    // Debug: log first stock object to discover actual field names
    const firstKey = Object.keys(marketData)[0];
    if (firstKey) console.log('[Stock Advisor] Sample stock data:', JSON.stringify(marketData[firstKey], null, 2));
    const firstHeld = Object.keys(portfolioData)[0];
    if (firstHeld) console.log('[Stock Advisor] Sample portfolio data:', JSON.stringify(portfolioData[firstHeld], null, 2));
  }
  // ── ANALYSIS ENGINE ─────────────────────────────────────
  function analyzeStock(id, stock) {
    const held = portfolioData[id];
    const benefitCost = stock.benefit ? stock.benefit.requirement * stock.current_price : null;
    // 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 priceChange = getPriceChange(id);
    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,
        description: stock.benefit.description,
        payoutsPerYear: 365 / freq,
        note: knownValue ? knownValue.note : '',
        tier: knownValue ? knownValue.tier : null
      };
      if (knownValue && knownValue.annual > 0) {
        annualROI = (knownValue.annual / bbCost) * 100;
      }
    }
    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 total_shares — it's the authoritative count.
      // Transaction sums can overcount when shares have been sold
      // (buy transactions may remain in history after partial sells).
      let totalShares = txnShares;
      if (held.total_shares != null) {
        totalShares = held.total_shares;
        // 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);
        }
      }
      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;
      position = {
        totalShares,
        avgBuy,
        currentValue,
        costBasis,
        pnl,
        pnlPct,
        hasBenefit,
        dividendReady,
        dividendProgress,
        // Only shares beyond the last complete benefit block are truly "extra"
        // (e.g. 1,500,000 shares / 750,000 req = 2 full BBs, 0 extra)
        sharesAboveBB: hasBenefit ? totalShares % stock.benefit.requirement : 0
      };
    }
    // Technical analysis from Tornsy data
    const tornsyData = tornsyCache[stock.acronym];
    const technicals = analyzeOHLC(tornsyData);
    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,
      technicals
    };
  }
  function generateRecommendations(analyses) {
    const recs = [];
    for (const a of analyses) {
      // ── 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
        if (a.position.hasBenefit && 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 no benefit and profitable
        if (!a.position.hasBenefit && !a.benefit && a.position.pnlPct > 2) {
          recs.push({
            stock: a,
            action: 'SELL',
            priority: 50 + a.position.pnlPct,
            reason: `You're up ${a.position.pnlPct.toFixed(1)}% and this stock has no benefit — good time to take your profit`,
            color: '#4caf50'
          });
        }
        // Sell warning: losing money and price still falling
        const priceDropping = a.priceChange !== null && a.priceChange < -1;
        if (!a.position.hasBenefit && a.position.pnlPct < -5 && priceDropping) {
          recs.push({
            stock: a,
            action: 'SELL',
            priority: 40,
            reason: `You're down ${Math.abs(a.position.pnlPct).toFixed(1)}% and the price is still falling (${a.priceChange.toFixed(1)}%/hr) — sell to stop losing more`,
            color: '#f44336'
          });
        }
        // Big loss warning
        if (!a.position.hasBenefit && a.position.pnlPct < -10 && !priceDropping) {
          recs.push({
            stock: a,
            action: 'CHECK',
            priority: 35,
            reason: `You're down ${Math.abs(a.position.pnlPct).toFixed(1)}% with no benefit — think about selling to stop losses`,
            color: '#ff9800'
          });
        }
        // Keep — you're getting the benefit
        if (a.position.hasBenefit && a.position.pnlPct >= -5) {
          recs.push({
            stock: a,
            action: 'KEEP',
            priority: 10,
            reason: `You're getting the benefit (next payout: ${a.position.dividendProgress}) — ${a.position.pnlPct >= 0 ? 'up' : 'down'} ${Math.abs(a.position.pnlPct).toFixed(1)}%`,
            color: '#2196f3'
          });
        }
      }
      // ── BUY 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-tier passive benefits — always active, no collecting needed
        if (tier === 'S') {
          recs.push({
            stock: a,
            action: 'BUY',
            priority: 80,
            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,
            reason: `Great perk: ${a.benefitROI.description || a.benefitROI.note} — hold ${fmtNum(sharesNeeded)} shares (costs $${fmtMoney(a.bbCost)})`,
            color: '#00bcd4'
          });
        }
        // High return from payouts (>15% per year)
        else if (a.annualROI > 15) {
          recs.push({
            stock: a,
            action: 'BUY',
            priority: 50 + a.annualROI,
            reason: `Good earner! Pays ~${a.annualROI.toFixed(0)}% per year in rewards — hold ${fmtNum(sharesNeeded)} shares (costs $${fmtMoney(a.bbCost)})`,
            color: '#4caf50'
          });
        }
        // Decent return (5-15% per year)
        else if (a.annualROI > 5) {
          recs.push({
            stock: a,
            action: 'MAYBE BUY',
            priority: 20 + a.annualROI,
            reason: `Decent earner — pays ~${a.annualROI.toFixed(0)}% per year in rewards — costs $${fmtMoney(a.bbCost)}`,
            color: '#78909c'
          });
        }
        // Otherwise: no recommendation (not every stock deserves one)
      }
      // ── PRICE MOMENTUM signals (any stock, held or not) ──
      if (a.technicals) {
        const t = a.technicals;
        // Price is low and recovering — good time to buy
        if (t.momentum >= 1.5 && !a.position) {
          const details = [];
          if (t.change7d !== null) details.push(`${t.change7d >= 0 ? '+' : ''}${t.change7d.toFixed(1)}% this week`);
          recs.push({
            stock: a,
            action: 'BUY DIP',
            priority: 45 + t.momentum * 5,
            reason: `${icon('trending-up', '#4caf50', 14)} Price is low and starting to go up — ${details.join(', ')} — could be a good entry point`,
            color: '#9c27b0'
          });
        }
        // Price looks like it's peaking — sell while you're ahead
        if (t.momentum <= -1.5 && a.position && a.position.pnlPct > 3 && !a.position.hasBenefit) {
          recs.push({
            stock: a,
            action: 'SELL NOW',
            priority: 55,
            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 benefit
        if (t.momentum <= -1 && a.position && a.position.pnlPct < -3 && !a.position.hasBenefit) {
          recs.push({
            stock: a,
            action: 'SELL',
            priority: 42,
            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 benefit = might drop soon
        if (a.position && !a.position.hasBenefit && a.position.pnlPct > 1
          && t.rangePos > 80 && t.change30d !== null && t.change30d > 5
          && t.momentum <= 0.5) {
          recs.push({
            stock: a,
            action: 'SELL SOON',
            priority: 48,
            reason: `${icon('warning', '#ff9800', 14)} You're up ${a.position.pnlPct.toFixed(1)}% and price is near its 30-day high (up ${t.change30d.toFixed(1)}% this month) — it could start dropping, consider locking in your profit`,
            color: '#ff9800'
          });
        }
        // Held stock that had a big run-up and is now slowing down
        if (a.position && !a.position.hasBenefit && a.position.pnlPct > 0
          && t.change7d !== null && t.change30d !== null
          && t.change30d > 8 && t.change7d < 1 && t.momentum <= 0) {
          recs.push({
            stock: a,
            action: 'WATCH',
            priority: 30,
            reason: `${icon('eye', '#78909c', 14)} Price went up ${t.change30d.toFixed(1)}% this month but has slowed down this week (${t.change7d >= 0 ? '+' : ''}${t.change7d.toFixed(1)}%) — the run might be ending`,
            color: '#78909c'
          });
        }
      }
    }
    // ── REBALANCE signals — cross-portfolio optimization ──
    const rebalanceRecs = generateRebalanceRecommendations(analyses);
    recs.push(...rebalanceRecs);

    recs.sort((a, b) => b.priority - a.priority);
    return recs;
  }
  // ── 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
    const sources = analyses
      .filter(a => a.position)
      .map(a => {
        let sellableShares = 0;
        if (!a.benefit) {
          sellableShares = a.position.totalShares;
        } else if (a.position.hasBenefit && a.position.sharesAboveBB > 0) {
          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?.note || target.analysis.benefitROI?.description || '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(analyses) {
    const opportunities = findRebalanceOpportunities(analyses);
    return opportunities.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);

      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 >= 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');
  }
  // ── 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',
  };
  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: 100000;
        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-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-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: 50px 1fr 70px 65px 50px;
        gap: 6px;
        align-items: center;
        font-size: 12px;
      }
      .tsa-portfolio-row .tsa-stock-name { font-size: 12px; }
      .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: 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;
      }
      .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 {
          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;
          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: 1fr 1fr 1fr;
          grid-template-rows: auto auto;
        }
        .tsa-portfolio-header {
          grid-template-columns: 1fr 1fr 1fr;
        }
        #tsa-setup input {
          font-size: 16px;
          padding: 10px 12px;
        }
        #tsa-setup button {
          padding: 10px 24px;
          font-size: 14px;
        }
      }
    `;
    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="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: inline for PDA/mobile, fixed overlay for desktop
    if (IS_PDA) {
      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);
      }
    } else {
      document.body.appendChild(panel);
    }
    // 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 === 'clear-data') {
        if (confirm('Remove your stored API key and cached data?')) {
          apiKey = '';
          _gmSet('tornStockAdvisor_apiKey', '');
          _gmSet('tornStockAdvisor_priceHistory', '{}');
          priceHistory = {};
          showSetup();
        }
      } else if (action === 'about') {
        alert(
          'Torn Stock Advisor v1.6.6\n\n' +
          '\u2022 Read-only \u2014 uses official Torn API only\n' +
          '\u2022 Your API key is stored locally\n' +
          '\u2022 Never sent anywhere except api.torn.com\n' +
          '\u2022 2 API calls per refresh (60s interval)\n' +
          '\u2022 No automated trading \u2014 display only' +
          (IS_PDA ? '\n\u2022 TornPDA compatible' : '')
        );
      }
    });
    // 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);
      }
    });
    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);
      await refresh();
    });
  }
  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();
      status.textContent = `Updated ${now.toLocaleTimeString()} · ${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);
  }
  function forecastTag(forecast) {
    if (!forecast) return '';
    const cls = ['Very Good', 'Good'].includes(forecast) ? 'good'
      : ['Poor', 'Very Poor'].includes(forecast) ? 'bad'
        : 'neutral';
    return `<span class="tsa-tag ${cls}">${forecast}</span>`;
  }
  function demandTag(demand) {
    if (!demand) return '';
    const cls = ['Very High', 'High'].includes(demand) ? 'good'
      : ['Low', 'Very Low'].includes(demand) ? 'bad'
        : 'neutral';
    return `<span class="tsa-tag ${cls}">${demand}</span>`;
  }
  function priceChangeTag(pct) {
    if (pct === null || pct === undefined) return '';
    const cls = pct > 0.5 ? 'good' : pct < -0.5 ? 'bad' : 'neutral';
    const arrow = pct > 0 ? '▲' : pct < 0 ? '▼' : '~';
    return `<span class="tsa-tag ${cls}">${arrow} ${Math.abs(pct).toFixed(2)}%</span>`;
  }
  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) {
    if (cachedRecs.length === 0) {
      body.innerHTML = '<div class="tsa-empty">Nothing to do right now — no buying or selling opportunities detected.</div>';
      return;
    }
    body.innerHTML = cachedRecs.map(r => `
      <div class="tsa-card">
        <span class="tsa-badge" 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('');
  }
  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(1)}%)</span>
      </div>
      <div class="tsa-portfolio-header">
        <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.hasBenefit ? icon('check', '#4caf50', 14) : (a.benefit ? icon('square', '#555', 14) : '—');
      const divIcon = p.dividendReady ? ' ' + icon('bell', '#e6b800', 14) : '';
      return `<div class="tsa-portfolio-row">
          <span class="tsa-stock-name">${a.acronym}${divIcon}</span>
          <span>${fmtNum(p.totalShares)} ${bbIcon} ${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(1)}%</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;
    });
    body.innerHTML = `
      <div class="tsa-portfolio-header" style="grid-template-columns: 50px 65px 65px 50px 55px 1fr">
        <div>Stock</div>
        <div>Price</div>
        <div>Trend</div>
        <div>Heat</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
        ? `<span style="color:${t.rsi < 30 ? '#4caf50' : t.rsi > 70 ? '#f44336' : '#888'}">${t.rsi < 30 ? 'Cheap' : t.rsi > 70 ? 'Pricey' : 'Fair'}</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.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" style="grid-template-columns: 50px 65px 65px 50px 55px 1fr">
          <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">${bbInfo}</span>
        </div>`;
    }).join('')}
    `;
  }
  // ── 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 v1.6.6\n\n' +
        '• Read-only — uses official Torn API only\n' +
        '• Your API key is stored locally in Tampermonkey\n' +
        '• Never sent anywhere except api.torn.com\n' +
        '• 2 API calls per refresh (60s interval)\n' +
        '• No automated trading — display only'
      );
    });
  }
  // ── 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 = {}; }
    injectStyles();
    // Add stock abbreviation labels on native stock logos (useful on mobile)
    addStockAbbreviations();
    const stockObserver = new MutationObserver(() => addStockAbbreviations());
    const stockList = document.querySelector('[class*="stockMarket___"]') || document.querySelector('#stockmarketroot') || document.body;
    stockObserver.observe(stockList, { childList: true, subtree: true });
    createPanel();
    if (!apiKey) {
      showSetup();
      return;
    }
    await refresh();
    // Auto-refresh every 60s
    setInterval(refresh, CACHE_TTL);
  }
  // Wait for page to be ready
  if (document.readyState === 'complete') {
    init();
  } else {
    window.addEventListener('load', init);
  }
})();