diff --git a/.env.example b/.env.example index 745cbe0..6bb0218 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,13 @@ ADMIN_ALERT_EMAIL= CUSTOMER_SUPPORT_EMAIL= RISK_ADMIN_WALLET= +# Trade email alerts +# Use either Resend directly or a webhook from Zapier/Make/another email automation. +RESEND_API_KEY= +TRADE_EMAIL_FROM=Poly Arena +TRADE_EMAIL_REPLY_TO= +TRADE_EMAIL_WEBHOOK_URL= + # Agent chat OPENAI_API_KEY= OPENAI_MODEL=gpt-5.6-luna diff --git a/api/live.js b/api/live.js index 02c77d8..724067f 100644 --- a/api/live.js +++ b/api/live.js @@ -245,6 +245,7 @@ function validateIntent(intent) { const VALID_TICKET_STATUSES = new Set(["staged", "reviewed", "placed_manually", "skipped", "cancelled"]); const VALID_FILL_ACTIONS = new Set(["BUY", "SELL"]); const VALID_CAPITAL_ACTIONS = new Set(["DEPOSIT", "WITHDRAW", "BUY_AGENT", "SELL_AGENT"]); +const VALID_TRADE_EMAIL_ACTIONS = new Set(["OPEN", "CLOSE", "EXIT", "STOP", "GAIN"]); const AGENT_CHAT_MAX_HISTORY = 12; const AGENT_CHAT_MAX_POSITIONS = 10; const AGENT_CHAT_MAX_SUGGESTIONS = 8; @@ -253,6 +254,107 @@ function cleanAgentText(value, max = 1200) { return String(value || "").replace(/\s+/g, " ").trim().slice(0, max); } +function cleanTradeEmailText(value, max = 500) { + return String(value || "").replace(/\s+/g, " ").trim().slice(0, max); +} + +function escapeHtml(value) { + return String(value || "").replace(/[&<>"]/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + }[c])); +} + +function normalizeTradeEmailEvents(events) { + return (Array.isArray(events) ? events : []).slice(0, 100).map((event) => { + const action = cleanTradeEmailText(event?.action, 12).toUpperCase(); + if (!VALID_TRADE_EMAIL_ACTIONS.has(action)) return null; + return { + agent_name: cleanTradeEmailText(event?.agent_name, 80), + agent_emoji: cleanTradeEmailText(event?.agent_emoji, 8), + agent_id: cleanTradeEmailText(event?.agent_id, 60), + action, + date: cleanTradeEmailText(event?.date, 40), + question: cleanTradeEmailText(event?.question, 220), + side: cleanTradeEmailText(event?.side, 12).toUpperCase(), + detail: cleanTradeEmailText(event?.detail, 320), + equity: Number.isFinite(Number(event?.equity)) ? Number(event.equity) : null, + return_pct: Number.isFinite(Number(event?.return_pct)) ? Number(event.return_pct) : null, + }; + }).filter(Boolean); +} + +function tradeEmailBody({ events, appUrl, test }) { + const title = test ? "Poly Arena test trade alert" : `Poly Arena trade alerts: ${events.length} new action${events.length === 1 ? "" : "s"}`; + const rows = events.map((event) => { + const perf = event.return_pct === null ? "" : ` · ${event.return_pct >= 0 ? "+" : ""}${event.return_pct.toFixed(2)}%`; + return `${event.agent_emoji || ""} ${event.agent_name || event.agent_id} ${event.action} ${event.side || ""}: ${event.question}${event.detail ? `\n ${event.detail}` : ""}${event.equity === null ? "" : `\n Equity: $${event.equity.toLocaleString("en-US", { maximumFractionDigits: 2 })}${perf}`}`; + }).join("\n\n"); + const text = `${title}\n\n${rows}\n\nPaper-trading alert only. Poly Arena did not place a live order or manage private keys.${appUrl ? `\n\nOpen Poly Arena: ${appUrl}` : ""}`; + const htmlRows = events.map((event) => { + const perf = event.return_pct === null ? "" : ` · ${event.return_pct >= 0 ? "+" : ""}${event.return_pct.toFixed(2)}%`; + return ` + ${escapeHtml(event.agent_emoji || "")} ${escapeHtml(event.agent_name || event.agent_id)}
${escapeHtml(event.date)} + ${escapeHtml(event.action)} ${escapeHtml(event.side || "")}
${escapeHtml(event.question)}${event.detail ? `
${escapeHtml(event.detail)}` : ""} + ${event.equity === null ? "" : `$${event.equity.toLocaleString("en-US", { maximumFractionDigits: 2 })}
${perf}`} + `; + }).join(""); + const html = `
+

${escapeHtml(title)}

+ ${htmlRows}
+

Paper-trading alert only. Poly Arena did not place a live order or manage private keys.

+ ${appUrl ? `

Open Poly Arena

` : ""} +
`; + return { subject: title, text, html }; +} + +async function sendTradeEmail({ to, events, appUrl, test }) { + const email = cleanTradeEmailText(to, 180); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return { status: 400, body: { ok: false, error: "Enter a valid email address." } }; + } + if (!events.length) { + return { status: 400, body: { ok: false, error: "No trade events to send." } }; + } + const payload = tradeEmailBody({ events, appUrl: cleanTradeEmailText(appUrl, 240), test }); + + if (process.env.RESEND_API_KEY) { + const from = process.env.TRADE_EMAIL_FROM || process.env.ADMIN_ALERT_EMAIL || "Poly Arena "; + const replyTo = process.env.TRADE_EMAIL_REPLY_TO || process.env.CUSTOMER_SUPPORT_EMAIL || undefined; + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to: email, reply_to: replyTo, subject: payload.subject, text: payload.text, html: payload.html }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) return { status: response.status, body: { ok: false, error: data?.message || data?.error || "Email provider rejected the alert." } }; + return { status: 200, body: { ok: true, provider: "resend", id: data?.id || null, sent: events.length } }; + } + + if (process.env.TRADE_EMAIL_WEBHOOK_URL) { + const response = await fetch(process.env.TRADE_EMAIL_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ to: email, ...payload, events }), + }); + if (!response.ok) return { status: response.status, body: { ok: false, error: "Trade email webhook rejected the alert." } }; + return { status: 200, body: { ok: true, provider: "webhook", sent: events.length } }; + } + + return { + status: 501, + body: { + ok: false, + error: "Email alerts need RESEND_API_KEY plus TRADE_EMAIL_FROM, or TRADE_EMAIL_WEBHOOK_URL, in Vercel environment variables.", + }, + }; +} + function safeAgentNumber(value, fallback = 0) { const n = Number(value); return Number.isFinite(n) ? n : fallback; @@ -523,6 +625,25 @@ export default async function handler(req, res) { if (req.method === "POST") { const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {}); + if (body.action === "trade_email") { + const events = normalizeTradeEmailEvents(body.events); + const result = await sendTradeEmail({ + to: body.to, + events, + appUrl: body.app_url, + test: Boolean(body.test), + }); + if (result.body?.ok) { + await recordAuditEvent("TRADE_EMAIL_ALERT_SENT", { + to_domain: String(body.to || "").split("@")[1] || null, + event_count: events.length, + test: Boolean(body.test), + provider: result.body.provider, + }); + } + return res.status(result.status).json(result.body); + } + if (body.action === "agent_chat") { return handleAgentChat(body, res); } diff --git a/index.html b/index.html index 2307292..7edf68c 100644 --- a/index.html +++ b/index.html @@ -396,6 +396,17 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
+ +
+

Trade Email Alerts

off
+ +
+
Send a digest whenever agents open, close, stop out, or take gains during a cycle.
+
@@ -726,7 +737,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
- Build performance-repair · Paper trading only · Live prices from Polymarket's public Gamma API · Not financial advice · + Build email-alerts · Paper trading only · Live prices from Polymarket's public Gamma API · Not financial advice · Source on GitHub
@@ -763,11 +774,12 @@ const PAPER_KEY = "pma_paper_accounts_v1"; const LIVE_KEY = "pma_live_readiness_v1"; const CHAT_KEY = "pma_agent_chat_v1"; const PAID_AGENT_CHAT_KEY = "pma_paid_agent_chat_v1"; +const EMAIL_ALERT_KEY = "pma_trade_email_alerts_v1"; const PAPER_SESSION_PREFIX = "pma_cloud_session_"; const PERSONAL_MODE = new URLSearchParams(location.search).get("personal") === "1"; const PERSONAL_USER_ID = "local-readiness-user"; const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"]; -const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY]; +const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY,EMAIL_ALERT_KEY]; const SYNC_LIMITS={closed:80,history:160,snapshots:240,suggestions:120,paperHistory:120,paperSnapshots:120,audit:120}; const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff", "Pop Culture":"#c77dff",Other:"#94a1bb",All:"#7c8cff",Copy:"#22d3ee"}; @@ -1136,6 +1148,80 @@ async function refreshFromCloudAndRender(silent=true){ return cloud; } +const EMAIL_ALERT_TYPES=[ + {id:"OPEN",label:"Opens"}, + {id:"CLOSE",label:"Closes"}, + {id:"EXIT",label:"Policy exits"}, + {id:"STOP",label:"Stop losses"}, + {id:"GAIN",label:"Gain stops"}, +]; +function defaultEmailAlerts(){ + return {enabled:false,email:"",events:{OPEN:true,CLOSE:true,EXIT:true,STOP:true,GAIN:true},last_sent_at:null,last_error:null}; +} +function loadEmailAlerts(){ + try{return Object.assign(defaultEmailAlerts(),JSON.parse(localStorage.getItem(EMAIL_ALERT_KEY)||"{}"));} + catch(e){return defaultEmailAlerts();} +} +function saveEmailAlerts(cfg){ + const base=defaultEmailAlerts(); + const merged=Object.assign(base,cfg||{}); + merged.email=String(merged.email||"").trim(); + merged.events=Object.assign(base.events,(cfg&&cfg.events)||{}); + merged.enabled=Boolean(merged.enabled&&validEmail(merged.email)); + localStorage.setItem(EMAIL_ALERT_KEY,JSON.stringify(merged)); + return merged; +} +function validEmail(email){return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email||"").trim());} +function agentHistoryOffsets(st){ + const offsets={}; + AGENTS.forEach(a=>{offsets[a.id]=((st.agents[a.id]&&st.agents[a.id].history)||[]).length;}); + return offsets; +} +function collectNewAgentEvents(st,offsets){ + const cfg=loadEmailAlerts(); + if(!cfg.enabled||!validEmail(cfg.email))return []; + const enabled=cfg.events||{}; + return AGENTS.flatMap(a=>{ + const p=st.agents[a.id]||defaultPortfolio(); + const start=Math.max(0,offsets[a.id]||0); + return (p.history||[]).slice(start).filter(h=>enabled[h.action]).map(h=>({ + agent_id:a.id, + agent_name:a.name, + agent_emoji:a.emoji, + action:h.action, + date:h.date, + question:h.question||"", + side:h.side||"", + detail:h.detail||"", + equity:+equity(p).toFixed(2), + return_pct:+((equity(p)/STARTING_BALANCE-1)*100).toFixed(2), + })); + }).slice(0,100); +} +async function notifyTradeDigest(events,{test=false}={}){ + const cfg=loadEmailAlerts(); + if(!events.length||!cfg.enabled||!validEmail(cfg.email))return {ok:false,skipped:true}; + try{ + const r=await fetch("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({ + action:"trade_email", + to:cfg.email, + events, + test, + app_url:location.origin+location.pathname, + })}); + const d=await r.json().catch(()=>({})); + if(!r.ok)throw new Error(d.error||"Email alert failed"); + saveEmailAlerts(Object.assign(cfg,{last_sent_at:nowIso(),last_error:null})); + if(test)toast("Test email sent."); + return d; + }catch(e){ + saveEmailAlerts(Object.assign(cfg,{last_error:e.message||"Email alert failed"})); + if(test)toast(e.message||"Email alert failed."); + else toast("Trade email alert failed. Check email provider settings."); + return {ok:false,error:e.message}; + }finally{renderEmailAlerts();} +} + /* ---------- Agent mechanics ---------- */ const equity=(p)=>p.cash+p.positions.reduce((s,x)=>s+x.shares*x.current_price,0); const hasPosition=(p,id)=>p.positions.some(x=>x.market_id===id); @@ -1761,6 +1847,7 @@ async function runDailyCycle(){ const sugs=generateSuggestions(markets); saveSuggestions(sugs,markets.length); st=loadState(); + const emailOffsets=agentHistoryOffsets(st); const focus=getFocus(); // Price map for suggestion-market positions (strategy + copycat only). const ids=new Set(); @@ -1803,10 +1890,12 @@ async function runDailyCycle(){ catch(e){recordSnapshot(st.agents[a.id]);} } st.date=todayStr();st.last_run=SNAP_TS;st.last_cycle_hour=hour; + const emailEvents=collectNewAgentEvents(st,emailOffsets); saveState(st); const pushed=await pushCloudState(); setStatus("up to date",false); if(!pushed)return {suggestions:(loadSuggestions().suggestions||[]).length,skipped:true,hour}; + await notifyTradeDigest(emailEvents); return {suggestions:sugs.length}; }finally{SNAP_TS=null;CYCLE_RUNNING=false;} } @@ -1821,7 +1910,7 @@ function board(){ const MEDALS=["🥇","🥈","🥉"]; /* ---------- Rendering ---------- */ -function renderAll(){renderOverview();renderLeaderboard();renderSuggestions();renderPortfolioTab();renderInvestTab();renderPaperTab();renderLiveMoneyTab();renderAbout();} +function renderAll(){renderOverview();renderEmailAlerts();renderLeaderboard();renderSuggestions();renderPortfolioTab();renderInvestTab();renderPaperTab();renderLiveMoneyTab();renderAbout();} function renderOverview(){ const b=board(); const lead=b[0]; @@ -1851,6 +1940,55 @@ function renderTopPicks(){
${esc(s.category)} BUY ${s.side} @ ${Math.round(s.entry_price*100)}¢
${Math.round(s.conviction)}
conviction
`).join(""); } +function renderEmailAlerts(){ + const input=$("emailAlertInput"),eventsRoot=$("emailAlertEvents"),meta=$("emailAlertMeta"),status=$("emailAlertStatus"); + if(!input||!eventsRoot)return; + const cfg=loadEmailAlerts(); + input.value=cfg.email||""; + meta.textContent=cfg.enabled?`on for ${cfg.email}`:"off"; + eventsRoot.innerHTML=EMAIL_ALERT_TYPES.map(t=>` + `).join(""); + status.textContent=cfg.last_error + ? `Last error: ${cfg.last_error}` + : (cfg.last_sent_at?`Last sent ${new Date(cfg.last_sent_at).toLocaleString()}.`:"Send a digest whenever agents open, close, stop out, or take gains during a cycle."); + $("saveEmailAlertsBtn").onclick=()=>{ + const email=input.value.trim(); + const events={}; + document.querySelectorAll("[data-email-event]").forEach(el=>{events[el.dataset.emailEvent]=el.checked;}); + const saved=saveEmailAlerts({enabled:validEmail(email),email,events,last_error:validEmail(email)?null:"Enter a valid email address."}); + toast(saved.enabled?"Trade email alerts saved.":"Enter a valid email to turn alerts on."); + renderEmailAlerts();pushCloudState(true); + }; + $("testEmailAlertsBtn").onclick=async()=>{ + const email=input.value.trim(); + const events={}; + document.querySelectorAll("[data-email-event]").forEach(el=>{events[el.dataset.emailEvent]=el.checked;}); + const saved=saveEmailAlerts({enabled:validEmail(email),email,events,last_error:null}); + if(!saved.enabled){toast("Enter a valid email first.");renderEmailAlerts();return;} + await notifyTradeDigest([{ + agent_id:"test", + agent_name:"Test Agent", + agent_emoji:"✉️", + action:"OPEN", + date:todayStr(), + question:"Test alert from Poly Arena", + side:"YES", + detail:"This confirms trade email alerts are connected.", + equity:STARTING_BALANCE, + return_pct:0, + }],{test:true}); + pushCloudState(true); + }; + document.querySelectorAll("[data-email-event]").forEach(el=>el.onchange=()=>{ + const email=input.value.trim(); + const events={}; + document.querySelectorAll("[data-email-event]").forEach(box=>{events[box.dataset.emailEvent]=box.checked;}); + saveEmailAlerts({enabled:validEmail(email),email,events,last_error:null}); + renderEmailAlerts();pushCloudState(true); + }); +} function miniSpark(snaps,color){ if(!snaps||snaps.length<2)return ``; const pts=snaps.map(s=>s.equity),W=120,H=34,p=3;