mirror of
https://github.com/theodore-song/polymarket-analyst.git
synced 2026-07-27 16:07:46 +00:00
Add agent trade email alerts
This commit is contained in:
@@ -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
@@ -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 `<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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user