const { useState, useRef, useEffect } = React;
const NO_CFD_RULE = "NEVER recommend CFDs, leveraged products, spread bets, derivatives, or leveraged ETFs. Stocks and plain ETFs only. No exceptions.";
const STORAGE_KEY = "pielot_watchlist_v1";
const QUESTIONS = [
{ id: "horizon", question: "When do you plan to use this money?", options: [
{ label: "Less than 1 year", value: "very_short", risk: 4 },
{ label: "1–3 years", value: "short", risk: 3 },
{ label: "3–7 years", value: "medium", risk: 2 },
{ label: "7+ years", value: "long", risk: 1 },
]},
{ id: "reaction", question: "Your portfolio drops 20% in a month. You...", options: [
{ label: "Sell everything immediately", value: "panic", risk: 4 },
{ label: "Lose sleep but hold", value: "worried", risk: 3 },
{ label: "Hold and wait it out", value: "calm", risk: 2 },
{ label: "Buy more - it's on sale", value: "greedy", risk: 1 },
]},
{ id: "goal", question: "What's your main investing goal?", options: [
{ label: "Protect my savings from inflation", value: "preserve", risk: 1 },
{ label: "Slow and steady wealth building", value: "grow_slow", risk: 2 },
{ label: "Aggressive growth", value: "grow_fast", risk: 3 },
{ label: "High risk, high reward plays", value: "speculate", risk: 4 },
]},
{ id: "knowledge", question: "How would you describe your investing knowledge?", options: [
{ label: "Total beginner", value: "beginner", risk: 1 },
{ label: "I know the basics", value: "basic", risk: 2 },
{ label: "Comfortable with markets", value: "intermediate", risk: 3 },
{ label: "I read earnings reports for fun", value: "advanced", risk: 4 },
]},
{ id: "amount", question: "What portion of your savings is this?", options: [
{ label: "Just a small experiment", value: "tiny", risk: 4 },
{ label: "About 10-25%", value: "small", risk: 3 },
{ label: "About 25-50%", value: "medium", risk: 2 },
{ label: "Most of my investable savings", value: "large", risk: 1 },
]},
];
const STRATEGIES = {
long_term_low_risk: { label: "Long-Term - Low Risk", color: "#4ade80", glow: "rgba(74,222,128,0.25)", desc: "Steady compounding over years. ETF-heavy, globally diversified, boring-but-powerful." },
long_term_high_risk: { label: "Long-Term - High Risk", color: "#60a5fa", glow: "rgba(96,165,250,0.25)", desc: "Thematic ETFs, growth stocks, emerging markets. Years of conviction required." },
short_term_high_risk: { label: "Short-Term - High Risk", color: "#f59e0b", glow: "rgba(245,158,11,0.25)", desc: "Momentum plays, earnings season picks, news-driven moves. Active and exciting." },
balanced: { label: "Balanced - All-Weather", color: "#a78bfa", glow: "rgba(167,139,250,0.25)", desc: "Sleep-well-at-night portfolio. Mix of growth, stability, and hedges." },
};
const SLICE_COLORS = [
{ hex: "#4ade80", rgb: "74,222,128" },
{ hex: "#60a5fa", rgb: "96,165,250" },
{ hex: "#f59e0b", rgb: "245,158,11" },
{ hex: "#a78bfa", rgb: "167,139,250" },
{ hex: "#f87171", rgb: "248,113,113" },
{ hex: "#34d399", rgb: "52,211,153" },
{ hex: "#818cf8", rgb: "129,140,248" },
{ hex: "#fb923c", rgb: "251,146,60" },
];
const SENT_COLORS = {
bullish: { color: "#4ade80", rgb: "74,222,128" },
bearish: { color: "#f87171", rgb: "248,113,113" },
neutral: { color: "#f59e0b", rgb: "245,158,11" },
};
function scoreToStrategy(s) {
if (s <= 8) return "long_term_low_risk";
if (s <= 12) return "balanced";
if (s <= 16) return "long_term_high_risk";
return "short_term_high_risk";
}
// Storage
function loadWL() { try { const r = localStorage.getItem(STORAGE_KEY); return r ? JSON.parse(r) : []; } catch { return []; } }
function saveWL(l) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(l)); } catch {} }
const PIES_KEY = "pielot_pies_v1";
function loadPies() { try { const r = localStorage.getItem(PIES_KEY); return r ? JSON.parse(r) : []; } catch { return []; } }
function savePies(l) { try { localStorage.setItem(PIES_KEY, JSON.stringify(l)); } catch {} }
function newPieId() { return "pie_" + Date.now(); }
// ─── Ticker Database ──────────────────────────────────────────────────────────
// Compact format: [ticker, name, type, sector]
// type: "S"=Stock, "E"=ETF
const RAW_TICKERS = [
// ── Mega/Large Cap US Stocks ──
["AAPL","Apple Inc","S","Tech"],["MSFT","Microsoft Corp","S","Tech"],["NVDA","NVIDIA Corp","S","Tech"],
["AMZN","Amazon.com Inc","S","Consumer"],["GOOGL","Alphabet Inc (Class A)","S","Tech"],["GOOG","Alphabet Inc (Class C)","S","Tech"],
["META","Meta Platforms Inc","S","Tech"],["TSLA","Tesla Inc","S","Auto/Tech"],["BRK.B","Berkshire Hathaway B","S","Finance"],
["UNH","UnitedHealth Group","S","Healthcare"],["JPM","JPMorgan Chase","S","Finance"],["V","Visa Inc","S","Finance"],
["XOM","ExxonMobil Corp","S","Energy"],["JNJ","Johnson & Johnson","S","Healthcare"],["WMT","Walmart Inc","S","Retail"],
["MA","Mastercard Inc","S","Finance"],["PG","Procter & Gamble","S","Consumer"],["HD","Home Depot Inc","S","Retail"],
["CVX","Chevron Corp","S","Energy"],["MRK","Merck & Co","S","Healthcare"],["ABBV","AbbVie Inc","S","Healthcare"],
["COST","Costco Wholesale","S","Retail"],["AVGO","Broadcom Inc","S","Tech"],["PEP","PepsiCo Inc","S","Consumer"],
["KO","Coca-Cola Co","S","Consumer"],["BAC","Bank of America","S","Finance"],["LLY","Eli Lilly and Co","S","Healthcare"],
["PFE","Pfizer Inc","S","Healthcare"],["TMO","Thermo Fisher Scientific","S","Healthcare"],["CSCO","Cisco Systems","S","Tech"],
["MCD","McDonald's Corp","S","Consumer"],["ACN","Accenture plc","S","Tech"],["ABT","Abbott Laboratories","S","Healthcare"],
["CRM","Salesforce Inc","S","Tech"],["DHR","Danaher Corp","S","Healthcare"],["NKE","Nike Inc","S","Consumer"],
["ADBE","Adobe Inc","S","Tech"],["TXN","Texas Instruments","S","Tech"],["LIN","Linde plc","S","Materials"],
["NEE","NextEra Energy","S","Utilities"],["PM","Philip Morris Intl","S","Consumer"],["RTX","RTX Corp","S","Aerospace"],
["HON","Honeywell Intl","S","Industrial"],["QCOM","Qualcomm Inc","S","Tech"],["UNP","Union Pacific Corp","S","Industrial"],
["AMGN","Amgen Inc","S","Healthcare"],["BMY","Bristol-Myers Squibb","S","Healthcare"],["SBUX","Starbucks Corp","S","Consumer"],
["IBM","IBM Corp","S","Tech"],["GE","GE Aerospace","S","Industrial"],["CAT","Caterpillar Inc","S","Industrial"],
["INTC","Intel Corp","S","Tech"],["LOW","Lowe's Companies","S","Retail"],["INTU","Intuit Inc","S","Tech"],
["DE","Deere & Company","S","Industrial"],["SPGI","S&P Global Inc","S","Finance"],["AMAT","Applied Materials","S","Tech"],
["MS","Morgan Stanley","S","Finance"],["GS","Goldman Sachs","S","Finance"],["BLK","BlackRock Inc","S","Finance"],
["AXP","American Express","S","Finance"],["MDT","Medtronic plc","S","Healthcare"],["GILD","Gilead Sciences","S","Healthcare"],
["TGT","Target Corp","S","Retail"],["ISRG","Intuitive Surgical","S","Healthcare"],["ADI","Analog Devices","S","Tech"],
["VRTX","Vertex Pharmaceuticals","S","Healthcare"],["REGN","Regeneron Pharmaceuticals","S","Healthcare"],
["MMC","Marsh & McLennan","S","Finance"],["SYK","Stryker Corp","S","Healthcare"],["LRCX","Lam Research","S","Tech"],
["MU","Micron Technology","S","Tech"],["ZTS","Zoetis Inc","S","Healthcare"],["CI","Cigna Group","S","Healthcare"],
["TJX","TJX Companies","S","Retail"],["PLD","Prologis Inc","S","Real Estate"],["AMT","American Tower Corp","S","Real Estate"],
["CB","Chubb Ltd","S","Finance"],["CL","Colgate-Palmolive","S","Consumer"],["DUK","Duke Energy","S","Utilities"],
["SO","Southern Co","S","Utilities"],["WM","Waste Management Inc","S","Industrial"],["ICE","Intercontinental Exchange","S","Finance"],
["CME","CME Group Inc","S","Finance"],["AON","Aon plc","S","Finance"],["PNC","PNC Financial Services","S","Finance"],
["USB","US Bancorp","S","Finance"],["TFC","Truist Financial","S","Finance"],["COF","Capital One Financial","S","Finance"],
["AIG","American Intl Group","S","Finance"],["MET","MetLife Inc","S","Finance"],["PRU","Prudential Financial","S","Finance"],
["HCA","HCA Healthcare","S","Healthcare"],["ELV","Elevance Health","S","Healthcare"],["CVS","CVS Health Corp","S","Healthcare"],
["MCK","McKesson Corp","S","Healthcare"],["AMP","Ameriprise Financial","S","Finance"],["ITW","Illinois Tool Works","S","Industrial"],
["ETN","Eaton Corp","S","Industrial"],["EMR","Emerson Electric","S","Industrial"],["PH","Parker-Hannifin","S","Industrial"],
["ROK","Rockwell Automation","S","Industrial"],["DOV","Dover Corp","S","Industrial"],["AME","AMETEK Inc","S","Industrial"],
// ── Tech / Growth ──
["NFLX","Netflix Inc","S","Tech"],["AMD","Advanced Micro Devices","S","Tech"],["PYPL","PayPal Holdings","S","Fintech"],
["SQ","Block Inc","S","Fintech"],["SHOP","Shopify Inc","S","Tech"],["SNOW","Snowflake Inc","S","Tech"],
["UBER","Uber Technologies","S","Tech"],["LYFT","Lyft Inc","S","Tech"],["ABNB","Airbnb Inc","S","Tech"],
["DASH","DoorDash Inc","S","Tech"],["COIN","Coinbase Global","S","Fintech"],["RBLX","Roblox Corp","S","Tech"],
["PLTR","Palantir Technologies","S","Tech"],["SOFI","SoFi Technologies","S","Fintech"],["HOOD","Robinhood Markets","S","Fintech"],
["RIVN","Rivian Automotive","S","Auto/Tech"],["LCID","Lucid Group","S","Auto/Tech"],["RKLB","Rocket Lab USA","S","Aerospace"],
["SPCE","Virgin Galactic","S","Aerospace"],["MNTS","Momentus Inc","S","Aerospace"],["RDW","Redwire Corp","S","Aerospace"],
["ASTR","Astra Space Inc","S","Aerospace"],["ASTS","AST SpaceMobile","S","Tech"],["SATL","Satellogic Inc","S","Tech"],
["NET","Cloudflare Inc","S","Tech"],["ZS","Zscaler Inc","S","Tech"],["CRWD","CrowdStrike Holdings","S","Tech"],
["OKTA","Okta Inc","S","Tech"],["DDOG","Datadog Inc","S","Tech"],["MDB","MongoDB Inc","S","Tech"],
["TTD","The Trade Desk","S","Tech"],["TWLO","Twilio Inc","S","Tech"],["U","Unity Software","S","Tech"],
["RGEN","Repligen Corp","S","Healthcare"],["VEEV","Veeva Systems","S","Tech"],["HUBS","HubSpot Inc","S","Tech"],
["ZM","Zoom Video Comm","S","Tech"],["DOCU","DocuSign Inc","S","Tech"],["BILL","Bill Holdings","S","Tech"],
["APP","AppLovin Corp","S","Tech"],["MSTR","MicroStrategy Inc","S","Tech"],["ARM","Arm Holdings","S","Tech"],
["SMCI","Super Micro Computer","S","Tech"],["DELL","Dell Technologies","S","Tech"],["HPQ","HP Inc","S","Tech"],
["HPE","Hewlett Packard Enterprise","S","Tech"],["ORCL","Oracle Corp","S","Tech"],["SAP","SAP SE","S","Tech"],
// ── Semiconductors ──
["ASML","ASML Holding NV","S","Semis"],["TSM","Taiwan Semiconductor","S","Semis"],["MRVL","Marvell Technology","S","Semis"],
["ON","ON Semiconductor","S","Semis"],["STM","STMicroelectronics","S","Semis"],["MPWR","Monolithic Power Systems","S","Semis"],
["WOLF","Wolfspeed Inc","S","Semis"],["SWKS","Skyworks Solutions","S","Semis"],["QRVO","Qorvo Inc","S","Semis"],
["KLAC","KLA Corp","S","Semis"],["MCHP","Microchip Technology","S","Semis"],["TER","Teradyne Inc","S","Semis"],
// ── Chinese / Asian Stocks ──
["BABA","Alibaba Group","S","Tech"],["BIDU","Baidu Inc","S","Tech"],["JD","JD.com Inc","S","Retail"],
["PDD","PDD Holdings","S","Retail"],["NIO","NIO Inc","S","Auto"],["XPEV","XPeng Inc","S","Auto"],
["LI","Li Auto Inc","S","Auto"],["TCEHY","Tencent Holdings","S","Tech"],["SE","Sea Ltd","S","Tech"],
["GRAB","Grab Holdings","S","Tech"],["GOTO","GoTo Group","S","Tech"],["SONY","Sony Group Corp","S","Tech"],
["TM","Toyota Motor Corp","S","Auto"],["HMC","Honda Motor Co","S","Auto"],["NVS","Novartis AG","S","Healthcare"],
["RHHBY","Roche Holding AG","S","Healthcare"],["NESNY","Nestlé SA","S","Consumer"],["LVMUY","LVMH","S","Luxury"],
["IDEXY","Industria de Diseno","S","Retail"],["SAB","Sabadell","S","Finance"],
// ── Energy ──
["SLB","SLB (Schlumberger)","S","Energy"],["HAL","Halliburton Co","S","Energy"],["BKR","Baker Hughes","S","Energy"],
["OXY","Occidental Petroleum","S","Energy"],["COP","ConocoPhillips","S","Energy"],["EOG","EOG Resources","S","Energy"],
["PSX","Phillips 66","S","Energy"],["VLO","Valero Energy","S","Energy"],["MPC","Marathon Petroleum","S","Energy"],
["DVN","Devon Energy","S","Energy"],["FANG","Diamondback Energy","S","Energy"],["APA","APA Corp","S","Energy"],
["ENPH","Enphase Energy","S","Clean Energy"],["SEDG","SolarEdge Technologies","S","Clean Energy"],
["RUN","Sunrun Inc","S","Clean Energy"],["PLUG","Plug Power Inc","S","Clean Energy"],["FSLR","First Solar Inc","S","Clean Energy"],
["BE","Bloom Energy","S","Clean Energy"],["NEP","NextEra Energy Partners","S","Clean Energy"],
// ── Finance / Fintech ──
["SCHW","Charles Schwab","S","Finance"],["STT","State Street Corp","S","Finance"],["NTRS","Northern Trust","S","Finance"],
["TROW","T. Rowe Price Group","S","Finance"],["BEN","Franklin Resources","S","Finance"],["IVZ","Invesco Ltd","S","Finance"],
["WFC","Wells Fargo","S","Finance"],["C","Citigroup Inc","S","Finance"],["DB","Deutsche Bank","S","Finance"],
["HSBC","HSBC Holdings","S","Finance"],["BCS","Barclays plc","S","Finance"],["UBS","UBS Group AG","S","Finance"],
["CS","Credit Suisse","S","Finance"],["ING","ING Groep NV","S","Finance"],["SAN","Banco Santander","S","Finance"],
["BBVA","BBVA","S","Finance"],["BNPQY","BNP Paribas","S","Finance"],["AXA","AXA SA","S","Finance"],
// ── Consumer / Retail ──
["AMZN","Amazon.com Inc","S","Retail"],["ETSY","Etsy Inc","S","Retail"],["EBAY","eBay Inc","S","Retail"],
["W","Wayfair Inc","S","Retail"],["RH","RH (Restoration Hardware)","S","Retail"],["LULU","Lululemon Athletica","S","Consumer"],
["GPS","Gap Inc","S","Retail"],["URBN","Urban Outfitters","S","Retail"],["ANF","Abercrombie & Fitch","S","Retail"],
["DG","Dollar General","S","Retail"],["DLTR","Dollar Tree Inc","S","Retail"],["KR","Kroger Co","S","Retail"],
["SFM","Sprouts Farmers Market","S","Retail"],["YUM","Yum! Brands","S","Consumer"],["QSR","Restaurant Brands Intl","S","Consumer"],
["CMG","Chipotle Mexican Grill","S","Consumer"],["DPZ","Domino's Pizza","S","Consumer"],["WING","Wingstop Inc","S","Consumer"],
["DKNG","DraftKings Inc","S","Entertainment"],["MGM","MGM Resorts Intl","S","Entertainment"],
["LVS","Las Vegas Sands","S","Entertainment"],["WYNN","Wynn Resorts","S","Entertainment"],["CZR","Caesars Entertainment","S","Entertainment"],
// ── Healthcare / Biotech ──
["MRNA","Moderna Inc","S","Biotech"],["BNTX","BioNTech SE","S","Biotech"],["BIIB","Biogen Inc","S","Biotech"],
["ILMN","Illumina Inc","S","Biotech"],["IDXX","Idexx Laboratories","S","Healthcare"],["MTD","Mettler-Toledo Intl","S","Healthcare"],
["A","Agilent Technologies","S","Healthcare"],["WAT","Waters Corp","S","Healthcare"],["TECH","Bio-Techne Corp","S","Healthcare"],
["DXCM","DexCom Inc","S","Healthcare"],["PODD","Insulet Corp","S","Healthcare"],["ALGN","Align Technology","S","Healthcare"],
["EW","Edwards Lifesciences","S","Healthcare"],["BSX","Boston Scientific","S","Healthcare"],["BDX","Becton Dickinson","S","Healthcare"],
["ZBH","Zimmer Biomet","S","Healthcare"],["HOLX","Hologic Inc","S","Healthcare"],["NTRA","Natera Inc","S","Biotech"],
["EXAS","Exact Sciences","S","Biotech"],["PACB","Pacific Biosciences","S","Biotech"],
// ── Crypto-adjacent / Meme ──
["GME","GameStop Corp","S","Retail"],["AMC","AMC Entertainment","S","Entertainment"],["BBBY","Bed Bath & Beyond","S","Retail"],
["BB","BlackBerry Ltd","S","Tech"],["NOK","Nokia Corp","S","Tech"],["SNDL","SNDL Inc","S","Cannabis"],
["TLRY","Tilray Brands","S","Cannabis"],["CGC","Canopy Growth Corp","S","Cannabis"],
// ── Broad Market ETFs ──
["SPY","SPDR S&P 500 ETF","E","Broad Market"],["VOO","Vanguard S&P 500 ETF","E","Broad Market"],
["IVV","iShares Core S&P 500 ETF","E","Broad Market"],["VTI","Vanguard Total Stock Market ETF","E","Broad Market"],
["ITOT","iShares Core S&P Total US ETF","E","Broad Market"],["SCHB","Schwab US Broad Market ETF","E","Broad Market"],
["VT","Vanguard Total World Stock ETF","E","Global"],["ACWI","iShares MSCI ACWI ETF","E","Global"],
["VXUS","Vanguard Total Intl Stock ETF","E","International"],["VEA","Vanguard FTSE Dev Markets ETF","E","International"],
["VWO","Vanguard FTSE Emerging Markets ETF","E","Emerging Markets"],["IEMG","iShares Core MSCI Emerging Markets ETF","E","Emerging Markets"],
["EEM","iShares MSCI Emerging Markets ETF","E","Emerging Markets"],["EFA","iShares MSCI EAFE ETF","E","International"],
["VWCE","Vanguard FTSE All-World UCITS ETF","E","Global"],["IWDA","iShares Core MSCI World UCITS ETF","E","Global"],
["CSPX","iShares Core S&P 500 UCITS ETF","E","Broad Market"],["EIMI","iShares Core MSCI EM IMI UCITS ETF","E","Emerging Markets"],
["SWRD","SPDR MSCI World UCITS ETF","E","Global"],["VGVF","Vanguard ESG Global All Cap UCITS ETF","E","ESG"],
// ── Sector ETFs ──
["QQQ","Invesco QQQ Trust (Nasdaq 100)","E","Tech"],["QQQM","Invesco Nasdaq 100 ETF","E","Tech"],
["XLK","Technology Select Sector SPDR","E","Tech"],["XLF","Financial Select Sector SPDR","E","Finance"],
["XLE","Energy Select Sector SPDR","E","Energy"],["XLV","Health Care Select Sector SPDR","E","Healthcare"],
["XLI","Industrial Select Sector SPDR","E","Industrial"],["XLY","Consumer Discret Select Sector SPDR","E","Consumer"],
["XLP","Consumer Staples Select Sector SPDR","E","Consumer"],["XLU","Utilities Select Sector SPDR","E","Utilities"],
["XLRE","Real Estate Select Sector SPDR","E","Real Estate"],["XLB","Materials Select Sector SPDR","E","Materials"],
["XLC","Communication Services Select Sector SPDR","E","Comms"],["SOXX","iShares Semiconductor ETF","E","Semis"],
["SMH","VanEck Semiconductor ETF","E","Semis"],["AIQ","Global X AI & Technology ETF","E","AI/Tech"],
["BOTZ","Global X Robotics & AI ETF","E","AI/Tech"],["ROBO","Robo Global Robotics ETF","E","AI/Tech"],
["HACK","ETFMG Prime Cyber Security ETF","E","Cybersecurity"],["BUG","Global X Cybersecurity ETF","E","Cybersecurity"],
["CLOUD","Global X Cloud Computing ETF","E","Tech"],["WCLD","WisdomTree Cloud Computing ETF","E","Tech"],
["FINX","Global X FinTech ETF","E","Fintech"],["KURE","KraneShares MSCI All China Health Care ETF","E","Healthcare"],
["ARKG","ARK Genomic Revolution ETF","E","Biotech"],["ARKK","ARK Innovation ETF","E","Growth"],
["ARKW","ARK Next Generation Internet ETF","E","Tech"],["ARKF","ARK Fintech Innovation ETF","E","Fintech"],
["ARKQ","ARK Autonomous Technology ETF","E","Tech"],["ARKX","ARK Space Exploration ETF","E","Aerospace"],
["ICLN","iShares Global Clean Energy ETF","E","Clean Energy"],["QCLN","First Trust NASDAQ Clean Edge ETF","E","Clean Energy"],
["TAN","Invesco Solar ETF","E","Clean Energy"],["FAN","First Trust Global Wind Energy ETF","E","Clean Energy"],
["VNQ","Vanguard Real Estate ETF","E","Real Estate"],["SCHH","Schwab US REIT ETF","E","Real Estate"],
["IYR","iShares US Real Estate ETF","E","Real Estate"],["AMT","American Tower Corp","S","Real Estate"],
// ── Dividend / Income ETFs ──
["VYM","Vanguard High Dividend Yield ETF","E","Dividend"],["DVY","iShares Select Dividend ETF","E","Dividend"],
["SCHD","Schwab US Dividend Equity ETF","E","Dividend"],["NOBL","ProShares S&P 500 Div Aristocrats ETF","E","Dividend"],
["JEPI","JPMorgan Equity Premium Income ETF","E","Income"],["JEPQ","JPMorgan Nasdaq Equity Premium Income ETF","E","Income"],
["SPHD","Invesco S&P 500 High Dividend Low Vol ETF","E","Dividend"],["HDV","iShares Core High Dividend ETF","E","Dividend"],
["SDY","SPDR S&P Dividend ETF","E","Dividend"],["VIG","Vanguard Dividend Appreciation ETF","E","Dividend"],
["DGRO","iShares Core Dividend Growth ETF","E","Dividend"],
// ── Factor / Smart Beta ETFs ──
["QUAL","iShares MSCI USA Quality Factor ETF","E","Factor"],["MTUM","iShares MSCI USA Momentum Factor ETF","E","Factor"],
["USMV","iShares MSCI USA Min Vol Factor ETF","E","Factor"],["VLUE","iShares MSCI USA Value Factor ETF","E","Factor"],
["EFAV","iShares MSCI EAFE Min Vol Factor ETF","E","Factor"],
// ── Bond ETFs ──
["AGG","iShares Core US Aggregate Bond ETF","E","Bonds"],["BND","Vanguard Total Bond Market ETF","E","Bonds"],
["TLT","iShares 20+ Year Treasury Bond ETF","E","Bonds"],["IEF","iShares 7-10 Year Treasury Bond ETF","E","Bonds"],
["SHY","iShares 1-3 Year Treasury Bond ETF","E","Bonds"],["LQD","iShares iBoxx $ Investment Grade Corp Bond ETF","E","Bonds"],
["HYG","iShares iBoxx $ High Yield Corp Bond ETF","E","Bonds"],["JNK","SPDR Bloomberg High Yield Bond ETF","E","Bonds"],
["BNDX","Vanguard Total Intl Bond ETF","E","Bonds"],["EMB","iShares JP Morgan USD Emerging Markets Bond ETF","E","Bonds"],
["TIPS","iShares TIPS Bond ETF","E","Bonds"],["VTIP","Vanguard Short-Term Inflation-Protected ETF","E","Bonds"],
// ── Commodity ETFs ──
["GLD","SPDR Gold Shares ETF","E","Gold"],["IAU","iShares Gold Trust","E","Gold"],
["SLV","iShares Silver Trust","E","Silver"],["GLDM","SPDR Gold MiniShares","E","Gold"],
["PDBC","Invesco Optimum Yield Diversified Commodity ETF","E","Commodities"],
["DJP","iPath Bloomberg Commodity Index ETN","E","Commodities"],
["USO","United States Oil Fund","E","Oil"],["BNO","United States Brent Oil Fund","E","Oil"],
["UNG","United States Natural Gas Fund","E","Gas"],
// ── Country ETFs ──
["EWJ","iShares MSCI Japan ETF","E","Japan"],["EWG","iShares MSCI Germany ETF","E","Germany"],
["EWU","iShares MSCI United Kingdom ETF","E","UK"],["EWQ","iShares MSCI France ETF","E","France"],
["EWI","iShares MSCI Italy ETF","E","Italy"],["EWC","iShares MSCI Canada ETF","E","Canada"],
["EWA","iShares MSCI Australia ETF","E","Australia"],["EWZ","iShares MSCI Brazil ETF","E","Brazil"],
["FXI","iShares China Large-Cap ETF","E","China"],["MCHI","iShares MSCI China ETF","E","China"],
["KWEB","KraneShares CSI China Internet ETF","E","China"],["INDA","iShares MSCI India ETF","E","India"],
["EWY","iShares MSCI South Korea ETF","E","South Korea"],["EWT","iShares MSCI Taiwan ETF","E","Taiwan"],
["VGK","Vanguard FTSE Europe ETF","E","Europe"],["IEUR","iShares Core MSCI Europe ETF","E","Europe"],
["EZU","iShares MSCI Eurozone ETF","E","Eurozone"],
];
// Build searchable objects from compact format
const TICKER_DB = RAW_TICKERS.map(([ticker,name,type,sector])=>({ticker,name,type,sector}));
// Fuzzy search: ticker prefix first, then name contains
function searchTickers(query, limit=8) {
if (!query || query.length < 1) return [];
const q = query.toUpperCase().trim();
const ql = query.toLowerCase().trim();
const exact = TICKER_DB.filter(t => t.ticker === q);
const tPrefix = TICKER_DB.filter(t => t.ticker !== q && t.ticker.startsWith(q));
const nStart = TICKER_DB.filter(t => !t.ticker.startsWith(q) && t.name.toLowerCase().startsWith(ql));
const nContains = TICKER_DB.filter(t => !t.ticker.startsWith(q) && !t.name.toLowerCase().startsWith(ql) && t.name.toLowerCase().includes(ql));
return [...exact, ...tPrefix, ...nStart, ...nContains].slice(0, limit);
}
// Validate unknown ticker via Claude (no web search, fast)
async function validateTicker(ticker) {
const body = {
model: "claude-sonnet-4-20250514", max_tokens: 120,
messages: [{ role: "user", content: `Is "${ticker}" a real publicly traded stock or ETF on any major exchange? If yes: {"known":true,"ticker":"EXACT_TICKER","name":"Full Company Name","type":"Stock or ETF","exchange":"NYSE/NASDAQ/etc"}. If no: {"known":false}. Raw JSON only. No markdown.` }],
};
try {
const endpoint = (typeof PielotConfig !== "undefined" && PielotConfig.apiUrl)
? PielotConfig.apiUrl
: "https://api.anthropic.com/v1/messages";
const headers = {"Content-Type":"application/json"};
if (typeof PielotConfig !== "undefined" && PielotConfig.nonce) headers["X-WP-Nonce"] = PielotConfig.nonce;
const res = await fetch(endpoint, { method:"POST", headers, body:JSON.stringify(body) });
const data = await res.json();
const text = data.content?.find(b=>b.type==="text")?.text?.trim()||"{}";
const s=text.indexOf("{"), e=text.lastIndexOf("}");
if(s===-1||e===-1) return {known:false};
return JSON.parse(text.slice(s,e+1));
} catch { return {known:false}; }
}
// ─── TickerSearch Component ───────────────────────────────────────────────────
function TickerSearch({ value, onChange, onSelect, placeholder, inputStyle, autoFocus }) {
const [query, setQuery] = useState(value||"");
const [results, setResults] = useState([]);
const [highlighted, setHighlighted] = useState(0);
const [open, setOpen] = useState(false);
const [validating, setValidating] = useState(false);
const wrapRef = useRef(null);
// Sync external value changes (e.g. clearing)
useEffect(()=>{ if(value==="") { setQuery(""); setResults([]); setOpen(false); } },[value]);
function handleInput(e) {
const v = e.target.value;
setQuery(v);
onChange && onChange(v);
if (v.trim().length > 0) {
const r = searchTickers(v);
setResults(r);
setOpen(true);
setHighlighted(0);
} else {
setResults([]);
setOpen(false);
}
}
function selectItem(item) {
setQuery(item.ticker);
setResults([]);
setOpen(false);
onChange && onChange(item.ticker);
onSelect && onSelect(item);
}
async function handleEnterOrSearch() {
if (!query.trim()) return;
// Check local DB first
const exact = TICKER_DB.find(t => t.ticker === query.trim().toUpperCase() || t.name.toLowerCase() === query.trim().toLowerCase());
if (exact) { selectItem(exact); return; }
// If there's a highlighted result, select it
if (open && results.length > 0) { selectItem(results[highlighted]); return; }
// Unknown - validate via Claude
setValidating(true); setOpen(false);
const v = await validateTicker(query.trim());
setValidating(false);
if (v.known) {
const item = { ticker: v.ticker||query.trim().toUpperCase(), name: v.name||query.trim(), type: v.type||"Stock", sector: v.exchange||"" };
selectItem(item);
} else {
onSelect && onSelect({ ticker: query.trim().toUpperCase(), name: query.trim(), type:"Stock", sector:"", unknown:true, notFound:true });
}
}
function handleKey(e) {
if (!open || results.length===0) {
if (e.key==="Enter") handleEnterOrSearch();
return;
}
if (e.key==="ArrowDown") { e.preventDefault(); setHighlighted(h=>Math.min(h+1,results.length-1)); }
else if (e.key==="ArrowUp") { e.preventDefault(); setHighlighted(h=>Math.max(h-1,0)); }
else if (e.key==="Enter") { e.preventDefault(); selectItem(results[highlighted]); }
else if (e.key==="Escape") { setOpen(false); }
}
// Close on outside click
useEffect(()=>{
function handle(e){ if(wrapRef.current&&!wrapRef.current.contains(e.target)) setOpen(false); }
document.addEventListener("mousedown",handle);
return ()=>document.removeEventListener("mousedown",handle);
},[]);
const typeColors = { S:"129,140,248", E:"74,222,128" };
return (
{open && results.length>0 && (
{results.map((r,i)=>(
selectItem(r)}
style={{display:"flex",alignItems:"center",gap:"0.75rem",padding:"0.65rem 0.9rem",cursor:"pointer",background:highlighted===i?"rgba(96,165,250,0.08)":"transparent",borderBottom:isetHighlighted(i)}>
{r.ticker}
{r.name}
{r.type}
))}
)}
);
}
// API
function withTimeout(promise, ms) {
let tid;
const timeout = new Promise((_, reject) => { tid = setTimeout(() => reject(new Error("SERVICE_TIMEOUT")), ms); });
return Promise.race([promise, timeout]).finally(() => clearTimeout(tid));
}
async function apiPost(body, logFn, label, cacheKey, forceFresh) {
const log = logFn || (() => {});
const t = Date.now();
let res, data;
const endpoint = (typeof PielotConfig !== "undefined" && PielotConfig.apiUrl)
? PielotConfig.apiUrl
: "https://api.anthropic.com/v1/messages";
const headers = { "Content-Type": "application/json" };
if (typeof PielotConfig !== "undefined" && PielotConfig.nonce) {
headers["X-WP-Nonce"] = PielotConfig.nonce;
}
// Inject cache fields if provided
const payload = { ...body };
if (cacheKey) payload.pielot_cache_key = cacheKey;
if (forceFresh) payload.pielot_force_fresh = true;
res = await fetch(endpoint, {
method: "POST", headers,
body: JSON.stringify(payload),
});
data = await res.json();
if (!res.ok) throw new Error(data.error?.message || data.message || `API error (${label})`);
// Show cache hit in debug log
const fromCache = data.pielot_from_cache === true;
const suffix = fromCache ? " 💾 (cached)" : "";
log(`${label}: ${((Date.now()-t)/1000).toFixed(1)}s - ${data.stop_reason}${suffix}`,
fromCache ? "info" : (data.stop_reason === "end_turn" ? "success" : "info"));
return data;
}
async function callClaude(prompt, logFn, cacheKey, forceFresh) {
const log = logFn || (() => {});
const baseBody = {
model: "claude-sonnet-4-20250514", max_tokens: 4096,
tools: [{ type: "web_search_20250305", name: "web_search" }],
messages: [{ role: "user", content: prompt }],
};
log("Sending to Claude API...", "info");
let data;
try {
data = await withTimeout(apiPost(baseBody, log, "Round 1", cacheKey, forceFresh), 120000);
} catch(e) {
throw new Error(e.message === "SERVICE_TIMEOUT" ? "SERVICE_TIMEOUT" : e.message);
}
if (data.pielot_from_cache) return data.content;
let messages = baseBody.messages;
let round = 1;
while (data.stop_reason === "tool_use" && round <= 3) {
const searches = data.content.filter(b => b.type === "tool_use");
log(`Web search round ${round}: ${searches.length} quer${searches.length>1?"ies":"y"}`, "info");
searches.forEach((s,i) => log(` [${i+1}] "${s.input?.query||"..."}"`, "search"));
const forceJSON = round >= 3;
messages = [
...messages,
{ role: "assistant", content: data.content },
{ role: "user", content: [{ type: "text", text: forceJSON
? "Output the final JSON now. Raw JSON only - no markdown, no backticks. Start with { and end with }."
: "Continue your research, then output the final JSON when ready."
}]},
];
round++;
try {
data = await withTimeout(apiPost({ ...baseBody, messages }, log, `Round ${round}`, cacheKey), 120000);
} catch(e) { throw new Error(e.message === "SERVICE_TIMEOUT" ? "SERVICE_TIMEOUT" : e.message); }
}
if (data.stop_reason === "tool_use") {
messages = [...messages, { role: "assistant", content: data.content },
{ role: "user", content: [{ type: "text", text: "Stop searching. Output the JSON now. Raw JSON only. Start with { end with }." }]}];
try {
data = await withTimeout(apiPost({ ...baseBody, messages }, log, "Final flush", cacheKey), 120000);
} catch(e) { throw new Error(e.message === "SERVICE_TIMEOUT" ? "SERVICE_TIMEOUT" : e.message); }
}
const block0 = data.content?.find(b => b.type === "text");
const text0 = block0?.text?.trim() || "";
if (!text0.includes("{") && data.stop_reason === "end_turn") {
messages = [...messages, { role: "assistant", content: data.content },
{ role: "user", content: [{ type: "text", text: "Output ONLY a JSON object. Start with { end with }. If not found: {\"not_found\":true,\"message\":\"reason\"}" }]}];
try {
data = await withTimeout(apiPost({ ...baseBody, messages }, log, "JSON recovery", cacheKey), 120000);
} catch(e) { throw new Error(e.message === "SERVICE_TIMEOUT" ? "SERVICE_TIMEOUT" : e.message); }
}
return data.content;
}
function stripCites(v) {
if (typeof v === "string") return v.replace(/]*>/gi,"").replace(/<\/cite>/gi,"").replace(/\s{2,}/g," ").trim();
if (Array.isArray(v)) return v.map(stripCites);
if (v && typeof v === "object") { const out={}; for(const k in v) out[k]=stripCites(v[k]); return out; }
return v;
}
function extractJSON(content) {
const block = content?.find(b => b.type === "text");
if (!block) throw new Error("No text block in response");
let text = block.text.trim()
.replace(/^```json\s*/i,"").replace(/^```\s*/i,"").replace(/```\s*$/i,"").trim();
const s = text.indexOf("{"), e = text.lastIndexOf("}");
if (s === -1 || e === -1) throw new Error(`No JSON found. Model said: "${text.slice(0,200)}"`);
try {
return stripCites(JSON.parse(text.slice(s, e+1)));
} catch(parseErr) {
throw new Error(`JSON parse failed: ${parseErr.message}. Preview: ${text.slice(s, s+200)}`);
}
}
// Styles
const C = {
app: { minHeight:"100vh", background:"#0a0f1e", backgroundImage:"radial-gradient(ellipse at 15% 60%,rgba(96,165,250,0.04) 0%,transparent 55%),radial-gradient(ellipse at 85% 15%,rgba(167,139,250,0.04) 0%,transparent 55%)", color:"white", fontFamily:"'Montserrat',sans-serif", display:"flex", flexDirection:"column", alignItems:"center", paddingBottom:"6rem" },
card: { background:"rgba(255,255,255,0.025)", border:"1px solid rgba(255,255,255,0.07)", borderRadius:"1.25rem", padding:"1.75rem", width:"100%", backdropFilter:"blur(12px)" },
btn: (v="primary") => ({ background: v==="primary" ? "linear-gradient(135deg,rgba(96,165,250,0.18),rgba(167,139,250,0.18))" : "rgba(255,255,255,0.04)", border: v==="primary" ? "1px solid rgba(96,165,250,0.35)" : "1px solid rgba(255,255,255,0.09)", color:"white", padding:"0.85rem 1.5rem", borderRadius:"0.75rem", fontSize:"0.9rem", cursor:"pointer", fontFamily:"'Montserrat',sans-serif", width:"100%" }),
label: { fontFamily:"monospace", fontSize:"0.6rem", letterSpacing:"0.15em", color:"rgba(255,255,255,0.25)", marginBottom:"0.5rem", borderBottom:"1px solid rgba(255,255,255,0.07)", paddingBottom:"0.3rem", display:"block" },
tag: (rgb) => ({ display:"inline-block", background:`rgba(${rgb},0.1)`, border:`1px solid rgba(${rgb},0.22)`, borderRadius:"0.3rem", padding:"0.12rem 0.45rem", fontSize:"0.6rem", fontFamily:"monospace", letterSpacing:"0.08em", color:`rgb(${rgb})` }),
input: { background:"rgba(255,255,255,0.04)", border:"1px solid rgba(255,255,255,0.1)", borderRadius:"0.75rem", padding:"0.85rem 1.1rem", color:"white", fontSize:"0.95rem", fontFamily:"'Montserrat',sans-serif", width:"100%", outline:"none", boxSizing:"border-box" },
};
function Spinner() {
return ;
}
function Badge({ sentiment, reason }) {
const sc = SENT_COLORS[sentiment] || SENT_COLORS.neutral;
return {sentiment||"-"}
{reason&&
{reason} }
;
}
function NewsItem({ n }) {
if (!n?.headline) return null;
return (
{n.headline}
{n.summary &&
{n.summary}
}
{n.source_name && (n.source_url?.startsWith("http")
?
{n.source_name} →
:
{n.source_name}
)}
);
}
function PartialDataNotice({ onRetry }) {
return (
PARTIAL DATA
Some information couldn't be retrieved right now.
{onRetry &&
Try again }
);
}
function isPartial(data) {
if (!data) return false;
const news = data.news || [];
const newsOk = news.filter(n => n?.headline).length >= 2;
// handles both {overview, sentiment, key_risks} and watchlist disp {sentiment, risks}
const hasOverview = !!(data.overview);
const hasSentiment = !!(data.sentiment);
const hasRisks = !!(data.key_risks || data.risks);
return !hasOverview || !hasSentiment || !newsOk || !hasRisks;
}
// Debug Panel
function DebugPanel({ logs }) {
const [open, setOpen] = useState(true);
const bottomRef = useRef(null);
useEffect(() => { if (open) bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [logs, open]);
const cm = { info:"rgba(255,255,255,0.5)", success:"#4ade80", error:"#f87171", search:"#60a5fa", warn:"#f59e0b" };
return
setOpen(o=>!o)} style={{background:"#0a0f1e",border:"1px solid rgba(255,255,255,0.12)",borderRadius:open?"0.75rem 0.75rem 0 0":"0.75rem",padding:"0.45rem 0.85rem",display:"flex",alignItems:"center",gap:"0.5rem",cursor:"pointer",userSelect:"none",borderBottom:open?"1px solid rgba(255,255,255,0.06)":undefined}}>
DEBUG {logs.length>0&&`(${logs.length})`}
{open?"▼":"▲"}
{open&&
{logs.length===0?
Waiting for activity... :logs.map((l,i)=>
{l.ts} {l.msg}
)}
}
;
}
// Nav
function Nav({ tab, setTab, wlCount, pieCount }) {
const tabs = [
{ key:"research", label:"Research" },
{ key:"watchlist", label:`Watchlist${wlCount>0?` (${wlCount})`:""}`},
{ key:"pie", label:`Build a Pie${pieCount>0?` (${pieCount})`:""}`},
];
return
{tabs.map(({key,label})=>
setTab(key)} style={{background:tab===key?"rgba(96,165,250,0.12)":"transparent",border:tab===key?"1px solid rgba(96,165,250,0.35)":"1px solid rgba(255,255,255,0.07)",color:tab===key?"white":"rgba(255,255,255,0.35)",padding:"0.55rem 1.25rem",borderRadius:"0.6rem",fontSize:"0.82rem",cursor:"pointer",fontFamily:"monospace",letterSpacing:"0.08em",textTransform:"uppercase"}}>{label} )}
PIELOT v0.5
;
}
// Add-to-Pie Modal
function AddToPieModal({ asset, pies, onAddToExisting, onCreateNew, onClose }) {
return (
e.stopPropagation()} style={{background:'#0d1526',border:'1px solid rgba(255,255,255,0.1)',borderRadius:'1.25rem',padding:'1.75rem',maxWidth:'420px',width:'100%',boxShadow:'0 0 60px rgba(0,0,0,0.7)'}}>
ADD TO PIE
{asset.ticker} {asset.name}
{pies.length > 0 ? (
YOUR PIES
{pies.map(pie => (
onAddToExisting(pie.id)} style={{background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:'0.65rem',padding:'0.7rem 1rem',cursor:'pointer',display:'flex',alignItems:'center',gap:'0.75rem',textAlign:'left'}}>
{pie.name}
{pie.holdings.length} holding{pie.holdings.length!==1?'s':''}
Add →
))}
) : (
You have no saved pies yet.
)}
+ Create new pie with {asset.ticker}
);
}
// Full asset card
function AssetCard({ result, watchlist, onAdd, onRemove, onRetry, pies, onAddToPie, onCreatePieWith }) {
const sc = SENT_COLORS[result.sentiment] || SENT_COLORS.neutral;
const strat = result.strategy_fit ? STRATEGIES[result.strategy_fit] : null;
const isWatched = watchlist.some(w => w.ticker === result.ticker);
const partial = isPartial(result);
const validNews = result.news?.filter(n => n?.headline) || [];
const [showPiePicker, setShowPiePicker] = useState(false);
return
{/* Header */}
{result.ticker}
{result.name && {result.name} }
{result.type && {result.type} }
{result.sector && {result.sector} }
{result.price_info &&
}
isWatched?onRemove(result.ticker):onAdd(result)} style={{background:isWatched?"rgba(74,222,128,0.08)":"rgba(255,255,255,0.04)",border:isWatched?"1px solid rgba(74,222,128,0.3)":"1px solid rgba(255,255,255,0.1)",color:isWatched?"#4ade80":"rgba(255,255,255,0.5)",borderRadius:"0.5rem",padding:"0.35rem 0.9rem",fontSize:"0.72rem",fontFamily:"monospace",cursor:"pointer"}}>{isWatched?"✓ Watching":"+ Watchlist"}
{onAddToPie &&
setShowPiePicker(true)} style={{background:"rgba(167,139,250,0.07)",border:"1px solid rgba(167,139,250,0.22)",color:"#a78bfa",borderRadius:"0.5rem",padding:"0.35rem 0.9rem",fontSize:"0.72rem",fontFamily:"monospace",cursor:"pointer"}}>+ Add to Pie }
{showPiePicker &&
{onAddToPie(id,result);setShowPiePicker(false);}} onCreateNew={()=>{onCreatePieWith(result);setShowPiePicker(false);}} onClose={()=>setShowPiePicker(false)}/>}
{/* Partial data notice */}
{partial &&
}
{/* Sentiment */}
{result.sentiment &&
}
{/* Overview */}
{result.overview &&
WHAT IS IT {result.overview}
}
{/* Strategy fit */}
{strat &&
BEST STRATEGY FIT {strat.label}
{result.strategy_fit_reason&&
{result.strategy_fit_reason}
}
}
{/* News */}
{validNews.length > 0 &&
RECENT NEWS {partial && validNews.length < 3 && - limited results } {validNews.map((n,i)=>)}
}
{/* Risks */}
{result.key_risks &&
KEY RISKS {result.key_risks}
}
{/* Reliability */}
{result.reliability_note &&
RELIABILITY NOTE {result.reliability_note}
}
AI-generated from public sources (Reuters, Bloomberg, CNBC, Yahoo Finance). Not financial advice.
;
}
function ServiceError({ onRetry }) {
return (
SERVICE UNAVAILABLE
Sorry, the research service is not responding right now. This is usually temporary - please try again in a moment.
{onRetry &&
Try again }
);
}
// Research Tab
function ResearchTab({ log, watchlist, onAdd, onRemove, pies, onAddToPie, onCreatePieWith }) {
const [selected, setSelected] = useState(null); // confirmed TickerSearch selection
const [inputVal, setInputVal] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState(null);
const [notFound, setNotFound] = useState(null);
const [error, setError] = useState(null);
const [history, setHistory] = useState([]);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!loading) { setElapsed(0); return; }
const t = setInterval(() => setElapsed(s => s+1), 1000);
return () => clearInterval(t);
}, [loading]);
async function search(ticker, name, forceFresh = false) {
const q = ticker || inputVal;
if (!q.trim()) return;
setLoading(true); setError(null); setResult(null); setNotFound(null);
log(`[Research] "${q}"`, "info");
const hint = name ? ` (${name})` : "";
const prompt = `You are a financial research assistant. Research this asset: "${q}"${hint}\n${NO_CFD_RULE}\nSearch for: what it does, current price, 5 recent news items, analyst sentiment, key risks, best strategy fit.\nIf the asset does not exist or cannot be identified as a real stock/ETF, output ONLY: {"not_found":true,"message":"brief explanation"}\nOtherwise output ALL fields below. Use whatever partial data you find - never leave the response empty.\nCRITICAL: Raw JSON only. No markdown. No backticks. Start { end }. All text max 60w. News max 25w. Real URLs only.\n{"ticker":"","name":"","type":"Stock or ETF","sector":"","price_info":"e.g. $182 (+1.2%)","overview":"max 50w","sentiment":"bullish","sentiment_reason":"max 20w","strategy_fit":"long_term_low_risk","strategy_fit_reason":"max 30w","news":[{"headline":"","summary":"max 25w","source_name":"Reuters","source_url":"https://..."},{"headline":"","summary":"","source_name":"Bloomberg","source_url":""},{"headline":"","summary":"","source_name":"CNBC","source_url":""},{"headline":"","summary":"","source_name":"Yahoo Finance","source_url":""},{"headline":"","summary":"","source_name":"FT","source_url":""}],"key_risks":"max 40w","reliability_note":"max 20w"}`;
try {
const cacheKey = forceFresh ? null : `research_${q.trim().toUpperCase().replace(/[^A-Z0-9]/g,"")}`;
const content = await callClaude(prompt, log, cacheKey, forceFresh);
const parsed = extractJSON(content);
if (parsed.not_found) {
setNotFound(parsed.message || `Couldn't find "${q}" as a known stock or ETF.`);
log(`[Research] Not found: ${q}`, "warn");
} else {
setResult(parsed);
if (parsed.ticker) setHistory(h=>[{ticker:parsed.ticker,name:parsed.name},...h.filter(x=>x.ticker!==parsed.ticker)].slice(0,8));
log(`[Research] Done: ${parsed.ticker} (${parsed.sentiment})`, "success");
}
} catch(e) {
if (e.message === "SERVICE_TIMEOUT") {
setError("SERVICE_TIMEOUT");
log(`[Research] Service timeout`, "error");
} else {
setError(e.message);
log(`[Research] Error: ${e.message}`, "error");
}
}
finally { setLoading(false); }
}
function handleSelect(item) {
if (item.notFound) {
setNotFound(`"${item.ticker}" doesn't appear to be a real stock or ETF.`);
setResult(null);
return;
}
setSelected(item);
setInputVal(item.ticker);
// Auto-search immediately on selection
search(item.ticker, item.name);
}
return
setInputVal(v)}
onSelect={handleSelect}
placeholder="Search ticker or company - e.g. NVDA, Apple, VWCE..."
inputStyle={{...C.input}}
/>
search()} disabled={loading||!inputVal.trim()} style={{...C.btn("primary"),width:"auto",padding:"0.85rem 1.5rem",opacity:loading||!inputVal.trim()?0.5:1,flexShrink:0}}>{loading?"...":"Search"}
{selected && !loading && !result && !notFound && !error && (
{selected.ticker}
{selected.name}
{selected.type && {selected.type} }
)}
{history.length>0&&
RECENT:
{history.map((h,i)=>{setInputVal(h.ticker);search(h.ticker,h.name);}} style={{background:"rgba(255,255,255,0.04)",border:"1px solid rgba(255,255,255,0.09)",borderRadius:"2rem",padding:"0.2rem 0.65rem",fontSize:"0.7rem",fontFamily:"monospace",color:"rgba(255,255,255,0.45)",cursor:"pointer"}}>{h.ticker} )}
}
{loading&&
Researching across live sources…
{elapsed>=60&&
Taking longer than usual…
}
}
{error&&!loading&&(error==="SERVICE_TIMEOUT"
?
search(result?.ticker||inputVal, result?.name)}/>
: {error}
)}
{notFound&&!loading&&(
NOT FOUND
{notFound}
Try the exact ticker symbol (e.g. AAPL , VWCE ), or the full company name.
)}
{result&&!loading&&search(result.ticker,result.name)} pies={pies} onAddToPie={onAddToPie} onCreatePieWith={onCreatePieWith}/>}
{!result&&!notFound&&!loading&&!error&&RESEARCH ANY ASSET
Search a ticker, company name, or ETF. Get price, news, risks and strategy fit - powered by live AI research.
}
;
}
// Watchlist Tab
function WatchlistTab({ log, watchlist, onRemove, onUpdateNote, pies, onAddToPie, onCreatePieWith }) {
const [expanded, setExpanded] = useState(null);
const [refreshing, setRefreshing] = useState(null);
const [fresh, setFresh] = useState({});
const [notes, setNotes] = useState({});
const [editingNote, setEditingNote] = useState(null);
const [piePicker, setPiePicker] = useState(null); // ticker of item showing pie picker
async function refresh(item) {
setRefreshing(item.ticker);
log(`[Watchlist] Refreshing ${item.ticker}...`, "info");
const prompt = `Quick update on ${item.ticker} - ${item.name}.\n${NO_CFD_RULE}\nSearch latest news and sentiment.\nCRITICAL: Raw JSON only. No markdown. Start { end }.\n{"price_info":"current price and % change","sentiment":"bullish","sentiment_reason":"max 20w","news":[{"headline":"","summary":"max 25w","source_name":"","source_url":"https://..."},{"headline":"","summary":"","source_name":"","source_url":""},{"headline":"","summary":"","source_name":"","source_url":""},{"headline":"","summary":"","source_name":"","source_url":""},{"headline":"","summary":"","source_name":"","source_url":""}],"key_risks":"max 40w"}`;
try {
const cacheKey = `refresh_${item.ticker.toUpperCase().replace(/[^A-Z0-9]/g,"")}`;
const content = await callClaude(prompt, log, cacheKey, false);
const parsed = extractJSON(content);
setFresh(d=>({...d,[item.ticker]:{...parsed,refreshed_at:new Date().toLocaleString("en-GB")}}));
log(`[Watchlist] ${item.ticker} refreshed - ${parsed.sentiment}`, "success");
} catch(e) { log(`[Watchlist] Error: ${e.message}`, "error"); }
finally { setRefreshing(null); }
}
function saveNote(ticker) {
const note = notes[ticker] ?? "";
setEditingNote(null);
onUpdateNote(ticker, note);
log(`[Watchlist] Note saved for ${ticker}`, "success");
}
if (watchlist.length===0) return
YOUR WATCHLIST IS EMPTY
Research any asset and click+ Watchlist to start tracking it.
;
return
{watchlist.length} ASSET{watchlist.length!==1?"S":""} TRACKED
{watchlist.map(item=>{
const isOpen=expanded===item.ticker;
const isRef=refreshing===item.ticker;
const f=fresh[item.ticker];
const disp={sentiment:f?.sentiment||item.sentiment,reason:f?.sentiment_reason||item.sentiment_reason,price:f?.price_info||item.price_info,news:f?.news||item.news,risks:f?.key_risks||item.key_risks};
const sc=SENT_COLORS[disp.sentiment]||SENT_COLORS.neutral;
return
setExpanded(isOpen?null:item.ticker)}>
{item.ticker} {item.name} {item.type}
{item.sector&&
{item.sector}
}
{disp.price&&
{disp.price} }
{disp.sentiment||"-"}
▼
{isOpen&&
refresh(item)} disabled={isRef} style={{background:"rgba(96,165,250,0.08)",border:"1px solid rgba(96,165,250,0.25)",color:"#60a5fa",borderRadius:"0.5rem",padding:"0.35rem 0.9rem",fontSize:"0.72rem",fontFamily:"monospace",cursor:isRef?"not-allowed":"pointer",opacity:isRef?0.5:1}}>{isRef?"Refreshing...":"Refresh news"}
setPiePicker(item.ticker)} style={{background:"rgba(167,139,250,0.07)",border:"1px solid rgba(167,139,250,0.22)",color:"#a78bfa",borderRadius:"0.5rem",padding:"0.35rem 0.9rem",fontSize:"0.72rem",fontFamily:"monospace",cursor:"pointer"}}>+ Add to Pie
onRemove(item.ticker)} style={{background:"rgba(248,113,113,0.06)",border:"1px solid rgba(248,113,113,0.2)",color:"#f87171",borderRadius:"0.5rem",padding:"0.35rem 0.9rem",fontSize:"0.72rem",fontFamily:"monospace",cursor:"pointer"}}>Remove
{piePicker===item.ticker&&
{onAddToPie(id,item);setPiePicker(null);}} onCreateNew={()=>{onCreatePieWith(item);setPiePicker(null);}} onClose={()=>setPiePicker(null)}/>}
Added {item.added_at}{f?.refreshed_at&&` · Updated ${f.refreshed_at}`}
{isPartial(disp)&&!isRef&&
refresh(item)}/>}
{disp.sentiment&&}
{item.overview&&}
{isRef&& }
{!isRef&&disp.news?.filter(n=>n?.headline).length>0&&RECENT NEWS{f?" - REFRESHED":""} {disp.news.filter(n=>n?.headline).map((n,i)=>)}
}
{!isRef&&disp.risks&&}
YOUR NOTE
{editingNote===item.ticker
?
:
{item.note||"No note yet."}
{setNotes(n=>({...n,[item.ticker]:item.note??""}));setEditingNote(item.ticker);}} style={{background:"transparent",border:"none",cursor:"pointer",fontFamily:"monospace",color:"rgba(255,255,255,0.3)",fontSize:"0.7rem",padding:"0.3rem 0.6rem",borderRadius:"0.4rem",flexShrink:0}}>Edit
}
}
;
})}
Watchlist saved locally in your browser. AI research from public sources. Not financial advice.
;
}
// Pie Chart
function PieChart({ allocations, onSliceClick }) {
const [hovered,setHovered]=useState(null);
const r=78,cx=100,cy=100; let cum=0;
const slices=allocations.map((a,i)=>{const start=cum;cum+=a.allocation;return{...a,start,...SLICE_COLORS[i%SLICE_COLORS.length]};});
function polar(a){const rad=((a-90)*Math.PI)/180;return{x:cx+r*Math.cos(rad),y:cy+r*Math.sin(rad)};}
function path(start,size,exp){const er=exp?r+6:r;if(size>=100)return `M ${cx} ${cy-er} A ${er} ${er} 0 1 1 ${cx-0.001} ${cy-er} Z`;const mid=((start+size/2)/100)*360,mRad=((mid-90)*Math.PI)/180,ox=exp?Math.cos(mRad)*5:0,oy=exp?Math.sin(mRad)*5:0,s=polar((start/100)*360),e=polar(((start+size)/100)*360);return `M ${cx+ox} ${cy+oy} L ${s.x+ox} ${s.y+oy} A ${er} ${er} 0 ${size>50?1:0} 1 ${e.x+ox} ${e.y+oy} Z`;}
return
{slices.map((s,i)=>setHovered(i)} onMouseLeave={()=>setHovered(null)} onClick={()=>onSliceClick&&onSliceClick(s)}/>)}
PIELOT
v0.4
{slices.map((s,i)=>
onSliceClick&&onSliceClick(s)} onMouseEnter={()=>setHovered(i)} onMouseLeave={()=>setHovered(null)} style={{display:"flex",alignItems:"center",gap:"0.6rem",cursor:"pointer",padding:"0.3rem 0.6rem",borderRadius:"0.4rem",background:hovered===i?"rgba(255,255,255,0.06)":"transparent"}}>
{s.ticker} {s.allocation}% →
)}
Click any ticker to research
;
}
// Company Card Modal
function CompanyCard({ asset, strategyLabel, onClose, log, watchlist, onAdd, onRemove }) {
const [data,setData]=useState(null); const [loading,setLoading]=useState(true); const [error,setError]=useState(null);
const [elapsed,setElapsed]=useState(0);
useEffect(()=>{ if(!loading){setElapsed(0);return;} const t=setInterval(()=>setElapsed(s=>s+1),1000); return()=>clearInterval(t); },[loading]);
async function fetchData() {
setLoading(true); setError(null); setData(null);
log(`[Card] Researching ${asset.ticker}...`,"info");
try {
const prompt=`Research ${asset.ticker} - ${asset.name} (${asset.type}) for "${strategyLabel}" strategy.\nReasoning: ${asset.reasoning||""}\n${NO_CFD_RULE}\nUser chose this strategy - never refuse. Output whatever you can find even if partial.\nCRITICAL: Raw JSON only. No markdown. Start { end }. Max 50w text. News max 25w. Real URLs only.\n{"overview":"max 50w","sentiment":"bullish","sentiment_reason":"max 20w","news":[{"headline":"","summary":"max 25w","source_name":"","source_url":"https://..."},{"headline":"","summary":"","source_name":"","source_url":""},{"headline":"","summary":"","source_name":"","source_url":""},{"headline":"","summary":"","source_name":"","source_url":""},{"headline":"","summary":"","source_name":"","source_url":""}],"why_in_pie":"max 40w","key_risks":"max 40w","reliability_note":"max 20w"}`;
const cacheKey = `card_${asset.ticker.toUpperCase().replace(/[^A-Z0-9]/g,"")}`;
const content = await callClaude(prompt, log, cacheKey, false);
const parsed=extractJSON(content);
setData(parsed); log(`[Card] ${asset.ticker} done`,"success");
} catch(e){setError(e.message);log(`[Card] Error: ${e.message}`,"error");}
finally{setLoading(false);}
}
useEffect(()=>{ fetchData(); },[]);
const isWatched=watchlist.some(w=>w.ticker===asset.ticker);
const partial=data&&isPartial(data);
const validNews=data?.news?.filter(n=>n?.headline)||[];
const isTimeout=error==="SERVICE_TIMEOUT";
return
e.stopPropagation()} style={{background:"#0d1526",border:"1px solid rgba(255,255,255,0.1)",borderRadius:"1.25rem",padding:"2rem",maxWidth:"560px",width:"100%",maxHeight:"88vh",overflowY:"auto",boxShadow:"0 0 80px rgba(0,0,0,0.7)"}}>
ASSET RESEARCH
{asset.ticker} {asset.name} {asset.type}{asset.allocation!=null&&` · ${asset.allocation}% allocation`}
{(data||error)&&isWatched?onRemove(asset.ticker):onAdd({...asset,...(data||{})})} style={{background:isWatched?"rgba(74,222,128,0.08)":"rgba(255,255,255,0.04)",border:isWatched?"1px solid rgba(74,222,128,0.3)":"1px solid rgba(255,255,255,0.1)",color:isWatched?"#4ade80":"rgba(255,255,255,0.5)",borderRadius:"0.5rem",padding:"0.35rem 0.9rem",fontSize:"0.7rem",fontFamily:"monospace",cursor:"pointer"}}>{isWatched?"✓ Watching":"+ Watchlist"} }
✕
{loading&&
Researching across live sources…
{elapsed>=60&&
Taking longer than usual…
}
}
{error&&!data&&
{isTimeout
? <>
SERVICE UNAVAILABLE
Sorry, the research service is not responding right now. This is usually temporary - please try again.
>
:
{error}
}
Try again
}
{data&&!loading&&
{partial&&
}
{data.sentiment&&}
{data.overview&&WHAT IS IT {data.overview}
}
{data.why_in_pie&&WHY IT'S IN YOUR PIE {data.why_in_pie}
}
{validNews.length>0&&RECENT NEWS{partial&&validNews.length<2&& - limited results } {validNews.map((n,i)=>)}
}
{data.key_risks&&KEY RISKS {data.key_risks}
}
{data.reliability_note&&RELIABILITY NOTE {data.reliability_note}
}
AI-generated from public sources. Not financial advice.
}
;
}
// ─── Custom Pie Builder ───────────────────────────────────────────────────────
function PieAnalysisPanel({ pie, log, onApplyRebalance }) {
const [action, setAction] = useState(null);
const [loading, setLoading] = useState(false);
const [response, setResponse] = useState(null);
const [rebalanceData, setRebalanceData] = useState(null); // structured rebalance result
const [applied, setApplied] = useState(false);
const holdings = pie.holdings.map(h => `${h.ticker} (${h.allocation}%)`).join(', ');
const REBALANCE_ID = 'rebalance_ai';
const actions = [
{ id:'strategy', label:'Analyse strategy', color:'#60a5fa', tip:'What strategy does this pie resemble? Is it coherent?', prompt:`Analyse this portfolio and tell me what investment strategy it most resembles. Is it coherent? Any obvious gaps or overlaps?\nHoldings: ${holdings}\n${NO_CFD_RULE}\nReply in plain English, max 120 words. No JSON.` },
{ id:'suggest', label:'Suggest companies', color:'#4ade80', tip:'Get 3–5 new stocks or ETFs that complement your current holdings.', prompt:`Given this portfolio, suggest 3-5 additional stocks or ETFs that would complement it well. Explain why each fits.\nHoldings: ${holdings}\n${NO_CFD_RULE}\nFor each suggestion: ticker, name, why it fits. Max 20w per suggestion. Plain English, no JSON.` },
{ id:'weights', label:'Suggest allocation tweaks', color:'#a78bfa', tip:'Flag over-concentrated positions and suggest concrete % adjustments.', prompt:`Review these portfolio allocations and suggest improvements. Flag anything over-concentrated or under-represented.\nHoldings: ${holdings}\n${NO_CFD_RULE}\nBe specific - suggest concrete % changes. Max 120 words. Plain English, no JSON.` },
{ id:'strategy_change', label:'Strategy change options', color:'#f59e0b', tip:'See what would need to change to shift toward a different strategy.', prompt:`Explain how this portfolio could be shifted toward each of the 4 strategies below. What would need to change for each?\nHoldings: ${holdings}\nStrategies: Long-Term Low Risk, Long-Term High Risk, Short-Term High Risk, Balanced All-Weather.\n${NO_CFD_RULE}\nMax 30w per strategy. Plain English, no JSON.` },
];
const rebalanceAction = { id: REBALANCE_ID, label: 'Auto Rebalance', color: '#f87171', tip: 'AI scans live market data and suggests new allocations. Apply in one click.' };
async function run(a) {
setAction(a.id); setLoading(true); setResponse(null); setRebalanceData(null); setApplied(false);
log(`[Pie AI] ${a.label}...`, 'info');
try {
const body = { model:'claude-sonnet-4-20250514', max_tokens:800, tools:[{type:'web_search_20250305',name:'web_search'}], messages:[{role:'user',content:a.prompt}] };
const endpoint = (typeof PielotConfig !== 'undefined' && PielotConfig.apiUrl) ? PielotConfig.apiUrl : 'https://api.anthropic.com/v1/messages';
const hdrs = {'Content-Type':'application/json'};
if (typeof PielotConfig !== 'undefined' && PielotConfig.nonce) hdrs['X-WP-Nonce'] = PielotConfig.nonce;
const res = await fetch(endpoint,{method:'POST',headers:hdrs,body:JSON.stringify(body)});
const data = await res.json();
const text = data.content?.find(b=>b.type==='text')?.text || 'No response.';
setResponse(stripCites([{type:'text',text}])?.[0]?.text ?? text);
log(`[Pie AI] Done`, 'success');
} catch(e) { setResponse('Error: ' + e.message); log(`[Pie AI] Error: ${e.message}`, 'error'); }
finally { setLoading(false); }
}
async function runRebalance() {
setAction(REBALANCE_ID); setLoading(true); setResponse(null); setRebalanceData(null); setApplied(false);
log('[Pie AI] Auto rebalance...', 'info');
const holdingsList = pie.holdings.map(h=>`${h.ticker} ${h.name||''} (${h.allocation}%)`).join(', ');
const totalPct = pie.holdings.reduce((s,h)=>s+Number(h.allocation),0);
const prompt = `You are a portfolio rebalancing assistant. Suggest allocation changes for this portfolio based on general investment principles and current market knowledge.
Holdings: ${holdingsList}
${NO_CFD_RULE}
Rules:
- Cover EVERY holding listed - no omissions
- Use EXACTLY the ticker symbols as listed - do not correct or change them
- "suggested" values MUST sum to exactly 100 - double-check your maths before outputting
- If you exit a position (suggested=0), redistribute that % across other holdings so total stays 100
- Current total is ${totalPct}% - your suggested total must equal 100%
- Action: "hold" (no change), "increase", "trim", "exit" (0%)
CRITICAL: Raw JSON only. No markdown. No backticks. Start { end }.
{"summary":"max 40 words","suggestions":[{"ticker":"AAPL","current":30,"suggested":25,"action":"trim","reason":"max 12 words"},{"ticker":"VTI","current":70,"suggested":75,"action":"increase","reason":"max 12 words"}]}`;
try {
const body = { model:'claude-sonnet-4-20250514', max_tokens:800, messages:[{role:'user',content:prompt}] };
const endpoint = (typeof PielotConfig !== 'undefined' && PielotConfig.apiUrl) ? PielotConfig.apiUrl : 'https://api.anthropic.com/v1/messages';
const hdrs = {'Content-Type':'application/json'};
if (typeof PielotConfig !== 'undefined' && PielotConfig.nonce) hdrs['X-WP-Nonce'] = PielotConfig.nonce;
const res = await fetch(endpoint,{method:'POST',headers:hdrs,body:JSON.stringify(body)});
const data = await res.json();
const text = data.content?.find(b=>b.type==='text')?.text || '';
const s = text.indexOf('{'), e = text.lastIndexOf('}');
if (s===-1||e===-1) throw new Error('No JSON in response');
const parsed = stripCites(JSON.parse(text.slice(s,e+1)));
// Normalise to exactly 100%
if (parsed.suggestions?.length) {
const rawTotal = parsed.suggestions.reduce((s,r)=>s+Number(r.suggested),0);
if (rawTotal !== 100 && rawTotal > 0) {
const scale = 100 / rawTotal;
let adjusted = parsed.suggestions.map(r=>({...r,suggested:Math.round(Number(r.suggested)*scale)}));
const adjTotal = adjusted.reduce((s,r)=>s+r.suggested,0);
const diff = 100 - adjTotal;
if (diff !== 0) { const mi = adjusted.reduce((mi,r,i,a)=>r.suggested>a[mi].suggested?i:mi,0); adjusted[mi]={...adjusted[mi],suggested:adjusted[mi].suggested+diff}; }
parsed.suggestions = adjusted;
}
}
setRebalanceData(parsed);
log('[Pie AI] Rebalance ready', 'success');
} catch(e) { setResponse('Error: ' + e.message); log(`[Pie AI] Error: ${e.message}`, 'error'); }
finally { setLoading(false); }
}
function applyRebalance() {
if (!rebalanceData?.suggestions) return;
const findSuggestion = (ticker) => rebalanceData.suggestions.find(
s => s.ticker.toUpperCase() === ticker.toUpperCase()
);
const updated = pie.holdings
.map(h => { const s = findSuggestion(h.ticker); return s ? {...h, allocation: s.suggested} : h; })
.filter(h => { const s = findSuggestion(h.ticker); return !s || s.suggested > 0; });
onApplyRebalance(updated);
setApplied(true);
log('[Pie AI] Rebalance applied', 'success');
}
const activeAction = actions.find(a=>a.id===action) || (action===REBALANCE_ID ? rebalanceAction : null);
const ACTION_COLORS = { hold:'rgba(255,255,255,0.3)', increase:'#4ade80', trim:'#f59e0b', exit:'#f87171' };
const ACTION_LABELS = { hold:'Hold', increase:'Increase', trim:'Trim', exit:'Exit' };
const suggestedTotal = rebalanceData?.suggestions?.reduce((s,r)=>s+Number(r.suggested),0) || 0;
return (
AI ANALYSIS
{[...actions, rebalanceAction].map(a => {
const isRebal = a.id === REBALANCE_ID;
const active = action === a.id;
return (
isRebal?runRebalance():run(a)} disabled={loading} style={{
background: active ? `${a.color}15` : 'rgba(255,255,255,0.03)',
border: `1px solid ${active ? a.color+'44' : 'rgba(255,255,255,0.08)'}`,
color: active ? a.color : 'rgba(255,255,255,0.5)',
borderRadius:'0.5rem', padding:'0.45rem 0.85rem',
fontSize:'0.75rem', cursor: loading?'wait':'pointer',
opacity: loading && !active ? 0.4 : 1, transition:'all 0.15s',
}}>{a.label}
{a.tip && {a.tip} }
);
})}
{loading &&
}
{/* Prose response for regular actions */}
{response && !loading && action !== REBALANCE_ID && (
{activeAction?.label?.toUpperCase()}
{response}
)}
{/* Structured rebalance result */}
{rebalanceData && !loading && (
AI REBALANCING SUGGESTIONS
{rebalanceData.summary &&
{rebalanceData.summary}
}
{/* Diff table */}
{rebalanceData.suggestions?.map((s,i) => {
const col = SLICE_COLORS[pie.holdings.findIndex(h=>h.ticker===s.ticker) % SLICE_COLORS.length] || SLICE_COLORS[i % SLICE_COLORS.length];
const actionColor = ACTION_COLORS[s.action] || ACTION_COLORS.hold;
const diff = s.suggested - s.current;
return (
{s.ticker}
{s.current}%
→
{s.suggested}%
{ACTION_LABELS[s.action]||s.action}
{diff!==0 && 0?'#4ade80':'#f59e0b'}}>{diff>0?'+':''}{diff}% }
{s.reason &&
{s.reason} }
);
})}
{/* Total check */}
SUGGESTED TOTAL
{suggestedTotal}%
{applied
?
✓ Rebalance applied to pie
:
Apply rebalance → {suggestedTotal!==100 && (total must be 100%) }
}
)}
{/* Error from rebalance */}
{response && !loading && action === REBALANCE_ID && (
{response}
)}
);
}
function SavedPieView({ pie, onEdit, onDelete, log, watchlist, onAdd, onRemove, onApplyRebalance }) {
const [selAsset, setSelAsset] = useState(null);
const total = pie.holdings.reduce((s,h)=>s+Number(h.allocation),0);
return (
SAVED PIE
{pie.name}
Created {pie.created_at} · {pie.holdings.length} holdings
Edit
Delete
({...h,allocation:Number(h.allocation)}))} onSliceClick={s=>setSelAsset({...s,hex:s.hex,rgb:s.rgb})} />
HOLDINGS - click any row to research
{pie.holdings.map((h,i) => {
const col = SLICE_COLORS[i % SLICE_COLORS.length];
return (
setSelAsset({...h,...col})} onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.05)'} onMouseLeave={e=>e.currentTarget.style.background='rgba(255,255,255,0.02)'} style={{display:'flex',alignItems:'center',gap:'0.75rem',background:'rgba(255,255,255,0.02)',border:'1px solid rgba(255,255,255,0.05)',borderLeft:`3px solid ${col.hex}`,borderRadius:'0.6rem',padding:'0.7rem 0.9rem',marginBottom:'0.4rem',cursor:'pointer'}}>
{h.ticker}
{h.name}
{h.type && {h.type} }
{h.allocation}%
→
);
})}
{
const updatedPie={...pie,holdings:updated};
onApplyRebalance(updatedPie);
}}/>
{selAsset && setSelAsset(null)} log={log} watchlist={watchlist} onAdd={onAdd} onRemove={onRemove}/>}
);
}
function CustomPieBuilder({ pies, onSave, onCancel, initialHolding, editingPie, log }) {
const [name, setName] = useState(editingPie?.name || '');
const [holdings, setHoldings] = useState(editingPie?.holdings || (initialHolding ? [{ ticker:initialHolding.ticker, name:initialHolding.name||'', type:initialHolding.type||'', allocation:'' }] : []));
const [newSearchInput, setNewSearchInput] = useState('');
const [newSearchKey, setNewSearchKey] = useState(0); // increment to reset TickerSearch
const [pendingItem, setPendingItem] = useState(null); // confirmed item from TickerSearch
const [addError, setAddError] = useState('');
const total = holdings.reduce((s,h)=>s+Number(h.allocation||0), 0);
const isValid = name.trim() && holdings.length >= 1 && total === 100;
function addHolding() {
if (!pendingItem) return;
const ticker = pendingItem.ticker.toUpperCase();
if (holdings.find(h=>h.ticker===ticker)) { setAddError(`${ticker} is already in this pie.`); return; }
setHoldings(prev=>[...prev,{ticker,name:pendingItem.name||'',type:pendingItem.type||'Stock',allocation:''}]);
setPendingItem(null); setNewSearchInput(''); setNewSearchKey(k=>k+1); setAddError('');
}
function handleTickerSelect(item) {
if (item.notFound) { setAddError(`"${item.ticker}" doesn't appear to be a real stock or ETF.`); setPendingItem(null); return; }
setPendingItem(item); setAddError('');
}
function removeHolding(i) { setHoldings(prev=>prev.filter((_,idx)=>idx!==i)); }
function updateAlloc(i,val) {
const v = val.replace(/[^0-9.]/g,'');
setHoldings(prev=>prev.map((h,idx)=>idx===i?{...h,allocation:v}:h));
}
function equalSplit() {
if (!holdings.length) return;
const each = Math.floor(10000/holdings.length)/100;
const rem = Math.round(100 - each*holdings.length);
setHoldings(prev=>prev.map((h,i)=>({...h,allocation:String(i===prev.length-1?each+rem:each)})));
}
function save() {
if (!isValid) return;
const pie = {
id: editingPie?.id || newPieId(),
name: name.trim(),
created_at: editingPie?.created_at || new Date().toLocaleDateString('en-GB'),
holdings: holdings.map(h=>({...h,allocation:Number(h.allocation)})),
};
onSave(pie);
log(`[Pies] ${editingPie?'Updated':'Saved'} "${pie.name}"`, 'success');
}
const totalColor = total===100?'#4ade80':total>100?'#f87171':'rgba(255,255,255,0.35)';
return (
{editingPie?'EDIT PIE':'NEW PIE'}
setName(e.target.value)}/>
{onCancel &&
✕ Cancel }
{/* Holdings list */}
{holdings.length > 0 && (
HOLDINGS
Equal split
{holdings.map((h,i) => {
const col = SLICE_COLORS[i % SLICE_COLORS.length];
return (
{h.ticker}
{h.name||h.type}
updateAlloc(i,e.target.value)}
style={{background:'rgba(255,255,255,0.06)',border:'1px solid rgba(255,255,255,0.12)',borderRadius:'0.4rem',padding:'0.3rem 0.5rem',color:'white',fontFamily:'monospace',fontSize:'0.88rem',fontWeight:700,width:'4rem',textAlign:'right',outline:'none'}}
placeholder="0" />
%
removeHolding(i)} style={{background:'transparent',border:'none',color:'rgba(248,113,113,0.4)',cursor:'pointer',fontSize:'0.9rem',padding:'0 0.2rem',flexShrink:0}}>×
);
})}
{/* Total */}
TOTAL
{total}%
{total!==100 && {total<100?`(${100-total}% remaining)`:`(${total-100}% over)`} }
)}
{/* Add new holding */}
ADD HOLDING
{setNewSearchInput(v); if(!v) setPendingItem(null);}}
onSelect={handleTickerSelect}
placeholder="Search ticker or company name..."
inputStyle={{...C.input,fontSize:'0.88rem',padding:'0.65rem 0.85rem'}}
/>
Add
{pendingItem && !addError && (
✓
{pendingItem.ticker}
{pendingItem.name}
{pendingItem.type && {pendingItem.type} }
)}
{addError &&
{addError}
}
{holdings.length===0 &&
Add at least one holding to get started.
}
{editingPie?'Save changes':'Save pie'} {isValid?'→':'(allocations must total 100%)'}
);
}
// Pie Builder Tab
function PieBuilderTab({ log, watchlist, onAdd, onRemove, pies, onSavePie, onDeletePie, initialCustomHolding, onClearInitialHolding }) {
// ALL hooks must be declared here, before any conditional returns
const [mode, setMode] = useState('guided');
const [viewPie, setViewPie] = useState(null);
const [editingPie, setEditingPie] = useState(null);
const [showNew, setShowNew] = useState(!!initialCustomHolding);
// Guided mode state - must live here even though only used in guided mode
const [phase, setPhase] = useState("intro");
const [currentQ, setCurrentQ] = useState(0);
const [answers, setAnswers] = useState({});
const [rec, setRec] = useState(null);
const [strat, setStrat] = useState(null);
const [guidedResult, setGuidedResult] = useState(null);
const [guidedError, setGuidedError] = useState(null);
const [selAsset, setSelAsset] = useState(null);
// If a holding was passed in from Research, switch to custom mode
useEffect(()=>{ if(initialCustomHolding){ setMode('custom'); setShowNew(true); } },[initialCustomHolding]);
function handleSave(pie) {
onSavePie(pie);
setShowNew(false); setEditingPie(null);
onClearInitialHolding && onClearInitialHolding();
setViewPie(pie.id);
}
function handleDelete(id) {
onDeletePie(id);
setViewPie(null);
}
// Mode toggle bar
const ModeToggle = () => (
{[['guided','Guided (AI)'],['custom','My Pies']].map(([m,l])=>(
{setMode(m);setViewPie(null);setShowNew(false);onClearInitialHolding&&onClearInitialHolding();}} style={{background:mode===m?'rgba(96,165,250,0.1)':'transparent',border:mode===m?'1px solid rgba(96,165,250,0.3)':'1px solid rgba(255,255,255,0.07)',color:mode===m?'white':'rgba(255,255,255,0.35)',padding:'0.45rem 1.1rem',borderRadius:'0.5rem',fontSize:'0.78rem',cursor:'pointer',fontFamily:'monospace',letterSpacing:'0.06em'}}>{l}
))}
);
// Custom / My Pies view
if (mode === 'custom') {
if (showNew || editingPie) {
return
{setShowNew(false);setEditingPie(null);onClearInitialHolding&&onClearInitialHolding();}} initialHolding={!editingPie?initialCustomHolding:null} editingPie={editingPie} log={log}/>
;
}
if (viewPie) {
const pie = pies.find(p=>p.id===viewPie);
if (pie) return
setEditingPie(pie)} onDelete={()=>handleDelete(pie.id)} log={log} watchlist={watchlist} onAdd={onAdd} onRemove={onRemove} onApplyRebalance={updatedPie=>{onSavePie(updatedPie);setViewPie(updatedPie.id);}}/>
;
}
// Pies list
return
{pies.length} SAVED PIE{pies.length!==1?'S':''}
setShowNew(true)} style={{background:'rgba(74,222,128,0.07)',border:'1px solid rgba(74,222,128,0.22)',color:'#4ade80',borderRadius:'0.5rem',padding:'0.35rem 0.9rem',fontSize:'0.72rem',fontFamily:'monospace',cursor:'pointer'}}>+ New pie
{pies.length===0
?
NO PIES YET
Build a pie manually - pick your own companies and set your own allocations.
setShowNew(true)} style={{...C.btn('primary'),maxWidth:'220px',margin:'0 auto'}}>Create my first pie →
: pies.map(pie=>{
const total=pie.holdings.reduce((s,h)=>s+Number(h.allocation),0);
return
setViewPie(pie.id)} style={{...C.card,padding:'1.1rem 1.25rem',cursor:'pointer',display:'flex',alignItems:'center',gap:'1rem'}} onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.04)'} onMouseLeave={e=>e.currentTarget.style.background='rgba(255,255,255,0.025)'}>
{pie.name}
{pie.holdings.slice(0,5).map((h,i)=>{h.ticker} {h.allocation}% )}
{pie.holdings.length>5&&+{pie.holdings.length-5} more }
Created {pie.created_at}
{total}% allocated
→
;
})}
;
}
// Guided mode handlers
function handleAnswer(q,opt){const na={...answers,[q.id]:opt};setAnswers(na);if(currentQ
s+o.risk,0);const r=scoreToStrategy(score);setRec(r);setStrat(r);setPhase("strategy");}}
async function gen(){
setPhase("loading");setGuidedError(null);log("[Pie] Starting...","info");
const s=STRATEGIES[strat];
const sum=Object.entries(answers).map(([id,opt])=>{const q=QUESTIONS.find(q=>q.id===id);return `- ${q.question}: "${opt.label}"`;}).join("\n");
const prompt=`You are Pielot, an AI portfolio analyst.\nINVESTOR:\n${sum}\nSTRATEGY: ${s.label} - ${s.desc}\nUser chose this. Never refuse.\n${NO_CFD_RULE}\nSearch macro conditions and asset news. Pick 5-8 assets on Trading212. Sum to 100%.\nCRITICAL: Raw JSON only. No markdown. Start { end }. Text max 25w.\n{"macro_summary":"max 30w","pie":[{"ticker":"","name":"","type":"ETF or Stock","allocation":30,"reasoning":"max 20w"}],"strategy_note":"max 25w","rebalance_hint":"max 25w"}`;
try{const content=await callClaude(prompt,log);const parsed=extractJSON(content);log(`[Pie] Done: ${parsed.pie?.length} assets, ${parsed.pie?.reduce((s,a)=>s+a.allocation,0)}%`,"success");setGuidedResult(parsed);setPhase("result");}
catch(err){log(`[Pie] Error: ${err.message}`,"error");setGuidedError(err.message);setPhase("strategy");}
}
const optBtn=(sel)=>({background:sel?"rgba(96,165,250,0.09)":"rgba(255,255,255,0.025)",border:`1px solid ${sel?"rgba(96,165,250,0.45)":"rgba(255,255,255,0.07)"}`,color:"white",padding:"0.9rem 1.2rem",borderRadius:"0.75rem",fontSize:"0.88rem",cursor:"pointer",fontFamily:"'Montserrat',sans-serif",textAlign:"left",width:"100%",marginBottom:"0.5rem"});
const sc=(s,sel)=>({background:sel?`${s.color}10`:"rgba(255,255,255,0.025)",border:`1px solid ${sel?s.color+"55":"rgba(255,255,255,0.07)"}`,borderRadius:"0.75rem",padding:"1rem 1.2rem",cursor:"pointer",marginBottom:"0.5rem",boxShadow:sel?`0 0 24px ${s.glow}`:"none"});
const mw={maxWidth:"640px",width:"100%"};
if(phase==="intro")return <>Build your pie Answer 5 questions about your goals and risk tolerance. We'll recommend a strategy and generate a personalised portfolio backed by live AI research.
setPhase("quiz")}>Start the questionnaire → >;
if(phase==="quiz"){const q=QUESTIONS[currentQ];return <>QUESTION {currentQ+1} OF {QUESTIONS.length}
{q.question} {q.options.map(opt=>
handleAnswer(q,opt)}>{opt.label} )}
>;}
if(phase==="strategy")return <>PROFILE COMPLETE
We recommend... Based on your answers. You're free to choose any strategy.
{Object.entries(STRATEGIES).map(([key,s])=>
setStrat(key)}>
{s.label} {key===rec&&RECOMMENDED }
{s.desc}
)}{guidedError&&
{guidedError}
}
Generate my pie → >;
if(phase==="loading")return <>Researching markets... Scanning live news, macro conditions, and building your personalised pie.
>;
if(phase==="result"&&guidedResult){
const result=guidedResult;
const s=STRATEGIES[strat];
return <>
{new Date().toLocaleDateString("en-GB")}
MACRO CONDITIONS {result.macro_summary}
ALLOCATIONS - click any row to research
{result.pie.map((asset,i)=>{const col=SLICE_COLORS[i%SLICE_COLORS.length];return
setSelAsset({...asset,...col})} onMouseEnter={e=>e.currentTarget.style.background="rgba(255,255,255,0.05)"} onMouseLeave={e=>e.currentTarget.style.background="rgba(255,255,255,0.02)"} style={{background:"rgba(255,255,255,0.02)",border:"1px solid rgba(255,255,255,0.06)",borderLeft:`3px solid ${col.hex}`,borderRadius:"0.75rem",padding:"0.85rem 1rem",marginBottom:"0.5rem",cursor:"pointer"}}>
{asset.ticker} {asset.name} {asset.allocation}%
{asset.type}
{asset.reasoning}
Click to research →
;})}
STRATEGY NOTE {result.strategy_note}
WATCH FOR REBALANCE {result.rebalance_hint}
Pielot uses AI to analyse public sources (Reuters, Bloomberg, CNBC, Yahoo Finance). Not financial advice.
{setPhase("strategy");setGuidedResult(null);}}>← Generate a different pie
{setPhase("intro");setAnswers({});setCurrentQ(0);setGuidedResult(null);setGuidedError(null);}}>Start over
{selAsset&&
setSelAsset(null)} log={log} watchlist={watchlist} onAdd={onAdd} onRemove={onRemove}/>}
>
}
return null;
}
// Root
function App() {
const [tab,setTab]=useState("research");
const [logs,setLogs]=useState([]);
const [watchlist,setWatchlist]=useState(()=>loadWL());
const [pies,setPies]=useState(()=>loadPies());
const [pendingHolding,setPendingHolding]=useState(null); // asset to add to custom builder
const [pieToast,setPieToast]=useState(null);
function log(msg,type="info"){const ts=new Date().toISOString().split("T")[1].slice(0,12);setLogs(prev=>[...prev.slice(-80),{ts,msg,type}]);}
// Watchlist
function addToWatchlist(d){
const entry={ticker:d.ticker,name:d.name,type:d.type,sector:d.sector||null,price_info:d.price_info||null,overview:d.overview||null,sentiment:d.sentiment||null,sentiment_reason:d.sentiment_reason||null,news:d.news||[],key_risks:d.key_risks||null,note:"",added_at:new Date().toLocaleDateString("en-GB")};
setWatchlist(prev=>{const u=[entry,...prev.filter(w=>w.ticker!==entry.ticker)];saveWL(u);return u;});
log(`[Watchlist] Added ${entry.ticker}`,"success");
}
function removeFromWatchlist(ticker){setWatchlist(prev=>{const u=prev.filter(w=>w.ticker!==ticker);saveWL(u);return u;});log(`[Watchlist] Removed ${ticker}`,"info");}
function updateNote(ticker,note){setWatchlist(prev=>{const u=prev.map(w=>w.ticker===ticker?{...w,note}:w);saveWL(u);return u;});}
// Pies
function savePie(pie){
setPies(prev=>{const u=[...prev.filter(p=>p.id!==pie.id),pie].sort((a,b)=>a.created_at{const u=prev.filter(p=>p.id!==id);savePies(u);return u;});log(`[Pies] Deleted`,"info");}
function addAssetToPie(pieId,asset){
const targetPie = pies.find(p=>p.id===pieId);
if(targetPie?.holdings.find(h=>h.ticker===asset.ticker)) {
setPieToast(`${asset.ticker} is already in "${targetPie.name}"`);
setTimeout(()=>setPieToast(null),3000); return;
}
setPies(prev=>{
const u=prev.map(p=>{
if(p.id!==pieId) return p;
return {...p,holdings:[...p.holdings,{ticker:asset.ticker,name:asset.name||"",type:asset.type||"Stock",allocation:0}]};
});
savePies(u);
return u;
});
log(`[Pies] Added ${asset.ticker} to pie`,"success");
const pieName = targetPie?.name || "pie";
setPieToast(`${asset.ticker} added to "${pieName}" - set its % in My Pies`);
setTimeout(()=>setPieToast(null), 4000);
}
function createPieWith(asset){
setPendingHolding(asset);
setTab("pie");
}
return
setPendingHolding(null)}/>
{pieToast&&{pieToast}
}
;
}
// Mount the app
(function() {
function mount() {
var el = document.getElementById("pielot-root");
if (el) ReactDOM.createRoot(el).render(React.createElement(App));
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount);
} else {
mount();
}
})();