Add agent trade email alerts

This commit is contained in:
Theodore Song
2026-07-25 07:48:06 -04:00
parent b986f932f0
commit 0417601c36
3 changed files with 269 additions and 3 deletions
+7
View File
@@ -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 <alerts@yourdomain.com>
TRADE_EMAIL_REPLY_TO=
TRADE_EMAIL_WEBHOOK_URL=
# Agent chat
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6-luna
+121
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
}[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 `<tr>
<td style="padding:10px;border-bottom:1px solid #e5e7eb"><b>${escapeHtml(event.agent_emoji || "")} ${escapeHtml(event.agent_name || event.agent_id)}</b><br><span style="color:#6b7280">${escapeHtml(event.date)}</span></td>
<td style="padding:10px;border-bottom:1px solid #e5e7eb"><b>${escapeHtml(event.action)} ${escapeHtml(event.side || "")}</b><br>${escapeHtml(event.question)}${event.detail ? `<br><span style="color:#6b7280">${escapeHtml(event.detail)}</span>` : ""}</td>
<td style="padding:10px;border-bottom:1px solid #e5e7eb;text-align:right">${event.equity === null ? "" : `$${event.equity.toLocaleString("en-US", { maximumFractionDigits: 2 })}<br><span style="color:${event.return_pct >= 0 ? "#047857" : "#dc2626"}">${perf}</span>`}</td>
</tr>`;
}).join("");
const html = `<div style="font-family:Inter,Arial,sans-serif;color:#111827">
<h2>${escapeHtml(title)}</h2>
<table style="border-collapse:collapse;width:100%;font-size:14px">${htmlRows}</table>
<p style="color:#6b7280;font-size:12px;margin-top:16px">Paper-trading alert only. Poly Arena did not place a live order or manage private keys.</p>
${appUrl ? `<p><a href="${escapeHtml(appUrl)}">Open Poly Arena</a></p>` : ""}
</div>`;
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 <onboarding@resend.dev>";
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);
}
+141 -3
View File
@@ -396,6 +396,17 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
<div id="topPicks"></div>
</div>
</div>
<div class="card">
<div class="card-h"><h3>Trade Email Alerts</h3><span class="small muted" id="emailAlertMeta">off</span></div>
<div class="account-row">
<input class="input" id="emailAlertInput" type="email" placeholder="you@example.com">
<button class="btn" id="saveEmailAlertsBtn">Save alerts</button>
<button class="btn ghost" id="testEmailAlertsBtn">Send test</button>
</div>
<div class="chiprow" id="emailAlertEvents" style="margin-top:10px"></div>
<div class="small muted" id="emailAlertStatus" style="margin-top:8px">Send a digest whenever agents open, close, stop out, or take gains during a cycle.</div>
</div>
</section>
<!-- ============ LEADERBOARD ============ -->
@@ -726,7 +737,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
</section>
<footer>
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 ·
<a class="market-link" href="https://github.com/theodore-song/polymarket-analyst" target="_blank" rel="noopener">Source on GitHub</a>
</footer>
</div>
@@ -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(){
<div class="sub"><span class="cat-badge" style="background:${catColor(s.category)}22;color:${catColor(s.category)}">${esc(s.category)}</span> BUY ${s.side} @ ${Math.round(s.entry_price*100)}¢</div>
</div><div class="right"><div style="font-family:'Space Grotesk';font-weight:700">${Math.round(s.conviction)}</div><div class="sub">conviction</div></div></div>`).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=>`
<label class="chip ${cfg.events&&cfg.events[t.id]?"active":""}" style="cursor:pointer">
<input type="checkbox" data-email-event="${t.id}" ${cfg.events&&cfg.events[t.id]?"checked":""} style="display:none"> ${t.label}
</label>`).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 `<svg width="120" height="34"></svg>`;
const pts=snaps.map(s=>s.equity),W=120,H=34,p=3;