diff --git a/.gitignore b/.gitignore
index a38ebeb..c79010a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules/
.DS_Store
.vercel
.claude/
+.env*
diff --git a/api/state.js b/api/state.js
new file mode 100644
index 0000000..25f5b46
--- /dev/null
+++ b/api/state.js
@@ -0,0 +1,44 @@
+import { get, put } from "@vercel/blob";
+
+const STATE_PATH = process.env.PMA_STATE_PATH || "shared/state.json";
+
+async function readJsonBlob() {
+ const blob = await get(STATE_PATH, { access: "private" });
+ if (!blob || blob.statusCode !== 200 || !blob.stream) return null;
+ const text = await new Response(blob.stream).text();
+ return text ? JSON.parse(text) : null;
+}
+
+export default async function handler(req, res) {
+ res.setHeader("Cache-Control", "no-store");
+ try {
+ if (!process.env.BLOB_READ_WRITE_TOKEN && !process.env.VERCEL_OIDC_TOKEN) {
+ return res.status(503).json({ ok: false, error: "Cloud state is not configured" });
+ }
+
+ if (req.method === "GET") {
+ return res.status(200).json({ ok: true, state: await readJsonBlob() });
+ }
+
+ if (req.method === "POST") {
+ const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {});
+ if (!body || typeof body !== "object" || !body.items || typeof body.items !== "object") {
+ return res.status(400).json({ ok: false, error: "Invalid state payload" });
+ }
+ const state = { version: 1, updated_at: new Date().toISOString(), items: body.items };
+ await put(STATE_PATH, JSON.stringify(state), {
+ access: "private",
+ allowOverwrite: true,
+ contentType: "application/json",
+ cacheControlMaxAge: 60,
+ });
+ return res.status(200).json({ ok: true, state });
+ }
+
+ res.setHeader("Allow", "GET, POST");
+ return res.status(405).json({ ok: false, error: "Method not allowed" });
+ } catch (err) {
+ const message = err && err.message ? err.message : "State sync failed";
+ return res.status(500).json({ ok: false, error: message });
+ }
+}
diff --git a/index.html b/index.html
index 8b1b802..069a178 100644
--- a/index.html
+++ b/index.html
@@ -381,9 +381,9 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
Device sync
-
Portfolios, returns, reports, suggestions, and chart history are saved in this browser. To make another device match, click Copy Sync here, then open the site on the other device and use Import Sync .
+
Portfolios, returns, reports, suggestions, and chart history now sync through the site automatically. Copy Sync and Import Sync remain as a fallback for moving the exact state between devices.
- ⚠️ Paper trading only — not financial advice. Nothing here places real orders or moves money. The analysis is a transparent heuristic. All portfolios live in this browser's local storage (each device keeps its own).
+ ⚠️ Paper trading only — not financial advice. Nothing here places real orders or moves money. The analysis is a transparent heuristic. Portfolios sync through the site when cloud state is available, with this browser keeping a local backup.
@@ -397,7 +397,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
/* ============================================================
Polymarket Arena — fully client-side.
Fetches live markets, scores them, and runs ten competing
- paper-trading agents. State persists in localStorage.
+ paper-trading agents. State syncs through /api/state when configured and uses localStorage as the on-device cache.
============================================================ */
const GAMMA = "https://gamma-api.polymarket.com";
@@ -582,6 +582,24 @@ function loadState(){
function saveState(st){localStorage.setItem(AGENTS_KEY,JSON.stringify(st));}
function loadSuggestions(){try{const s=localStorage.getItem(SUG_KEY);return s?JSON.parse(s):{date:null,suggestions:[]};}catch(e){return {date:null,suggestions:[]};}}
function saveSuggestions(sugs){const p={date:todayStr(),generated_at:nowIso(),suggestions:sugs};localStorage.setItem(SUG_KEY,JSON.stringify(p));return p;}
+function collectSyncItems(){const items={};SYNC_KEYS.forEach(k=>{const v=localStorage.getItem(k);if(v!=null)items[k]=v;});return items;}
+function applySyncItems(items){if(!items||typeof items!=="object")return false;SYNC_KEYS.forEach(k=>{if(items[k]!=null)localStorage.setItem(k,items[k]);});return true;}
+async function pushCloudState(){
+ try{
+ const r=await fetch("/api/state",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({items:collectSyncItems()})});
+ if(!r.ok)throw new Error("cloud sync unavailable");
+ return true;
+ }catch(e){return false;}
+}
+async function pullCloudState(){
+ try{
+ const r=await fetch("/api/state",{cache:"no-store"});
+ if(!r.ok)return false;
+ const d=await r.json();
+ if(d&&d.state&&applySyncItems(d.state.items)){toast("Cloud state loaded for this device.");return true;}
+ }catch(e){}
+ return false;
+}
/* ---------- Agent mechanics ---------- */
const equity=(p)=>p.cash+p.positions.reduce((s,x)=>s+x.shares*x.current_price,0);
@@ -995,6 +1013,7 @@ async function runDailyCycle(){
}
st.date=todayStr();st.last_run=SNAP_TS;
saveState(st);
+ await pushCloudState();
setStatus("up to date",false);
return {suggestions:sugs.length};
}finally{SNAP_TS=null;}
@@ -1333,8 +1352,7 @@ $("runBtn").addEventListener("click",async()=>{
const b64Encode=(s)=>btoa(Array.from(new TextEncoder().encode(s),b=>String.fromCharCode(b)).join(""));
const b64Decode=(s)=>new TextDecoder().decode(Uint8Array.from(atob(s),c=>c.charCodeAt(0)));
function buildSyncCode(){
- const data={version:1,exported_at:nowIso(),items:{}};
- SYNC_KEYS.forEach(k=>{const v=localStorage.getItem(k);if(v!=null)data.items[k]=v;});
+ const data={version:1,exported_at:nowIso(),items:collectSyncItems()};
return "PMA1."+b64Encode(JSON.stringify(data));
}
async function readClipboardOrPrompt(){
@@ -1354,25 +1372,27 @@ $("importStateBtn").addEventListener("click",async()=>{
const payload=JSON.parse(b64Decode(code.replace(/^PMA1\./,"")));
if(!payload||payload.version!==1||!payload.items)throw new Error("Bad sync code");
if(!confirm("Import this sync state and replace this device's current local portfolio data?"))return;
- SYNC_KEYS.forEach(k=>{if(payload.items[k]!=null)localStorage.setItem(k,payload.items[k]);});
+ applySyncItems(payload.items);
+ await pushCloudState();
toast("Sync imported. This device now matches the copied state.");
renderAll();showTab(location.hash.slice(1)||"portfolio");
}catch(e){toast("Import failed. Check that the sync code was copied fully.");}
});
$("resetBtn").addEventListener("click",()=>{
if(!confirm("Reset all ten agents to their $10,000 starting balance?"))return;
- saveState(defaultState());localStorage.removeItem(SUG_KEY);toast("All agents reset.");renderAll();
+ saveState(defaultState());localStorage.removeItem(SUG_KEY);pushCloudState();toast("All agents reset.");renderAll();
});
/* On load: first visit runs a 7-day backtest; afterwards a live daily update. */
(async function init(){
showTab(location.hash.slice(1)||"overview");
+ const cloudLoaded=await pullCloudState();
renderAll();
const st=loadState();
const btn=$("runBtn");
if(!st.seeded){
btn.disabled=true;btn.textContent="Backtesting…";
- try{await backtestWeek(st);st.date=todayStr();st.last_run=nowIso();saveState(st);renderAll();
+ try{await backtestWeek(st);st.date=todayStr();st.last_run=nowIso();saveState(st);await pushCloudState();renderAll();
toast("7-day backtest complete — live tracking begins today.");}
catch(e){setStatus("backtest error — click Run",false);}
btn.disabled=false;btn.textContent="Run cycle";
@@ -1381,7 +1401,7 @@ $("resetBtn").addEventListener("click",()=>{
try{await runDailyCycle();renderAll();toast("Daily update ready.");}
catch(e){setStatus("error — click Run to retry",false);}
btn.disabled=false;btn.textContent="Run cycle";
- }else{setStatus("up to date",false);}
+ }else{setStatus("up to date",false);if(!cloudLoaded)pushCloudState();}
})();