Spaces:
Running
Running
| // Retro Alpha — Browser-local game engine. | |
| // Mirrors engine.py: random walk, price shock, agent pressure, trades, | |
| // cost basis, history. Runs 100% in the browser; server is only for LLM. | |
| (function () { | |
| const ASSETS = ["cash", "fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"]; | |
| const REGIMES = [ | |
| "bull_market", "bear_market", "market_crash", "recovery", "high_inflation", | |
| "rate_hike", "rate_cut", "election_year", "monsoon_shock", "fii_exit", | |
| "tech_boom", "real_estate_boom", "crypto_frenzy", "gold_rush", "stagnation" | |
| ]; | |
| const ASSET_PARAMS = { | |
| cash: { mean: 0.00, vol: 0.01 }, | |
| fd: { mean: 0.065, vol: 0.005 }, | |
| gov_bonds: { mean: 0.07, vol: 0.06 }, | |
| nifty_50: { mean: 0.12, vol: 0.16 }, | |
| nifty_it: { mean: 0.15, vol: 0.28 }, | |
| real_estate:{ mean: 0.10, vol: 0.18 }, | |
| crypto: { mean: 0.20, vol: 0.65 }, | |
| gold: { mean: 0.08, vol: 0.14 }, | |
| }; | |
| const CORRELATION = 0.3; | |
| const STARTING_YEAR = 1994; | |
| const STARTING_MONTH = 4; | |
| const GAME_LENGTH_MONTHS = 120; | |
| const WIN_THRESHOLD = 2_000_000; | |
| const STARTING_CASH = 1_000_000; | |
| // --- helpers -------------------------------------------------------- | |
| function gaussian() { | |
| // Box-Muller standard normal | |
| const u1 = Math.random() || 1e-12; | |
| const u2 = Math.random(); | |
| return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); | |
| } | |
| function makePrices() { | |
| const p = {}; | |
| for (const a of ASSETS) p[a] = 1.0; | |
| return p; | |
| } | |
| // --- game state ----------------------------------------------------- | |
| function newGame() { | |
| return { | |
| year: STARTING_YEAR, | |
| month: STARTING_MONTH, | |
| months_elapsed: 0, | |
| prices: makePrices(), | |
| portfolio: Object.fromEntries(ASSETS.map((a) => [a, 0.0])), | |
| cost_basis: Object.fromEntries(ASSETS.map((a) => [a, 0.0])), | |
| cash_balance: STARTING_CASH, | |
| news: {}, | |
| agent_actions: [], | |
| ledger: [], | |
| game_over: false, | |
| won: false, | |
| last_event: {}, | |
| value_history: [STARTING_CASH], | |
| price_history: [], | |
| }; | |
| } | |
| function totalValue(s) { | |
| let v = s.cash_balance; | |
| for (const a of ASSETS) { | |
| if (a === "cash") continue; | |
| v += s.portfolio[a] * s.prices[a]; | |
| } | |
| return v; | |
| } | |
| function investedValue(s) { | |
| let v = 0; | |
| for (const a of ASSETS) { | |
| if (a === "cash") continue; | |
| v += s.portfolio[a] * s.prices[a]; | |
| } | |
| return v; | |
| } | |
| function totalPnl(s) { | |
| let pnl = 0; | |
| for (const a of ASSETS) { | |
| const current = s.portfolio[a] * s.prices[a]; | |
| pnl += current - s.cost_basis[a]; | |
| } | |
| return pnl; | |
| } | |
| // --- price dynamics ------------------------------------------------- | |
| function priceShock(s, impact) { | |
| for (const a of ASSETS) { | |
| if (a === "cash") continue; | |
| if (impact[a] !== undefined) { | |
| s.prices[a] = s.prices[a] * (1 + Number(impact[a])); | |
| } | |
| } | |
| } | |
| function randomWalk(s) { | |
| const tradable = ASSETS.filter((a) => a !== "cash"); | |
| const n = tradable.length; | |
| const shocks = tradable.map(() => gaussian()); | |
| const meanShock = shocks.reduce((a, b) => a + b, 0) / n; | |
| const correlated = shocks.map((z) => meanShock * CORRELATION + z * Math.sqrt(1 - CORRELATION * CORRELATION)); | |
| for (let i = 0; i < n; i++) { | |
| const asset = tradable[i]; | |
| const p = ASSET_PARAMS[asset]; | |
| const monthlyMean = p.mean / 12; | |
| const monthlyVol = p.vol / Math.sqrt(12); | |
| const ret = monthlyMean + monthlyVol * correlated[i]; | |
| s.prices[asset] = s.prices[asset] * (1 + ret); | |
| } | |
| } | |
| function applyAgentTrades(s, agentActions) { | |
| const pressure = Object.fromEntries(ASSETS.map((a) => [a, 0.0])); | |
| for (const action of agentActions || []) { | |
| for (const item of action.actions || []) { | |
| const asset = item.asset; | |
| if (!(asset in pressure)) continue; | |
| const amt = Number(item.amount_pct || 0) * (item.action === "buy" ? 1 : -1); | |
| pressure[asset] += amt; | |
| } | |
| } | |
| for (const a of ASSETS) { | |
| if (a === "cash") continue; | |
| s.prices[a] = s.prices[a] * (1 + pressure[a] * 0.03); | |
| } | |
| } | |
| // --- player trades -------------------------------------------------- | |
| function executePlayerTrade(s, asset, action, amountPct) { | |
| if (!(asset in s.prices)) { | |
| throw new Error("Unknown asset: " + asset); | |
| } | |
| const total = totalValue(s); | |
| let tradeValue = total * amountPct; | |
| if (action === "buy") { | |
| tradeValue = Math.min(tradeValue, s.cash_balance); | |
| if (tradeValue <= 0) return; | |
| const price = s.prices[asset]; | |
| const shares = tradeValue / price; | |
| s.cash_balance -= tradeValue; | |
| s.portfolio[asset] += shares; | |
| s.cost_basis[asset] += tradeValue; | |
| } else if (action === "sell") { | |
| const price = s.prices[asset]; | |
| const currentValue = s.portfolio[asset] * price; | |
| const sellValue = Math.min(tradeValue, currentValue); | |
| if (sellValue <= 0) return; | |
| const shares = sellValue / price; | |
| // Reduce cost basis proportionally (average-cost method) | |
| if (s.portfolio[asset] > 0) { | |
| const fractionSold = shares / s.portfolio[asset]; | |
| s.cost_basis[asset] = Math.max(0, s.cost_basis[asset] * (1 - fractionSold)); | |
| } | |
| s.portfolio[asset] -= shares; | |
| s.cash_balance += sellValue; | |
| } | |
| s.ledger.push({ | |
| month: s.month, year: s.year, asset, action, | |
| amount_pct: amountPct, value: tradeValue, | |
| }); | |
| } | |
| // --- advance month -------------------------------------------------- | |
| function advanceMonth(s, news, agentActions, event) { | |
| if (s.game_over) return; | |
| s.months_elapsed += 1; | |
| s.month += 1; | |
| if (s.month > 12) { s.month = 1; s.year += 1; } | |
| s.news = news || {}; | |
| s.agent_actions = agentActions || []; | |
| s.last_event = event || {}; | |
| if (event && event.impact) priceShock(s, event.impact); | |
| applyAgentTrades(s, agentActions); | |
| randomWalk(s); | |
| s.value_history.push(totalValue(s)); | |
| if (s.value_history.length > 240) { | |
| s.value_history = s.value_history.slice(-240); | |
| } | |
| const snap = {}; | |
| for (const a of ASSETS) snap[a] = s.prices[a]; | |
| s.price_history.push(snap); | |
| if (s.price_history.length > 240) { | |
| s.price_history = s.price_history.slice(-240); | |
| } | |
| if (s.months_elapsed >= GAME_LENGTH_MONTHS) { | |
| s.game_over = true; | |
| s.won = totalValue(s) >= WIN_THRESHOLD; | |
| } | |
| } | |
| // --- local NPC agents (deterministic, no LLM needed) --------------- | |
| function localAgentDecide(persona, state, event) { | |
| const regime = (event && event.regime) || "stagnation"; | |
| const crashy = ["market_crash", "bear_market", "fii_exit", "high_inflation"].includes(regime); | |
| const boomy = ["bull_market", "tech_boom", "recovery", "real_estate_boom"].includes(regime); | |
| let asset, action, amountPct, reason, sentiment; | |
| if (persona === "whale") { | |
| if (crashy) { | |
| asset = "gov_bonds"; action = "buy"; amountPct = 0.15; | |
| reason = "Flight to safety during the " + regime.replace(/_/g, " "); | |
| sentiment = "cautious"; | |
| } else if (boomy) { | |
| asset = "nifty_50"; action = "buy"; amountPct = 0.10; | |
| reason = "Risk-on into a " + regime.replace(/_/g, " "); | |
| sentiment = "bullish"; | |
| } else { | |
| asset = "fd"; action = "buy"; amountPct = 0.05; | |
| reason = "Park cash, wait for a clearer setup"; | |
| sentiment = "neutral"; | |
| } | |
| } else if (persona === "retail") { | |
| if (crashy) { | |
| asset = "nifty_it"; action = "sell"; amountPct = 0.20; | |
| reason = "Panic selling into the " + regime.replace(/_/g, " "); | |
| sentiment = "panic"; | |
| } else if (boomy) { | |
| asset = "nifty_50"; action = "buy"; amountPct = 0.10; | |
| reason = "FOMO is real"; | |
| sentiment = "bullish"; | |
| } else { | |
| asset = "gold"; action = "buy"; amountPct = 0.05; | |
| reason = "Safe haven while I figure this out"; | |
| sentiment = "neutral"; | |
| } | |
| } else { // permabull | |
| if (regime === "crypto_frenzy" || crashy) { | |
| asset = "crypto"; action = "buy"; amountPct = 0.20; | |
| reason = "Buy the dip. Crypto only goes up."; | |
| sentiment = "bullish"; | |
| } else { | |
| asset = "crypto"; action = "buy"; amountPct = 0.05; | |
| reason = "DCA forever"; | |
| sentiment = "bullish"; | |
| } | |
| } | |
| return { | |
| agent: persona, | |
| actions: [{ asset, action, amount_pct: amountPct, reason }], | |
| sentiment, | |
| }; | |
| } | |
| function allLocalAgentsDecide(state, event) { | |
| return ["whale", "retail", "permabull"].map((p) => localAgentDecide(p, state, event)); | |
| } | |
| // --- public API ----------------------------------------------------- | |
| window.RetroEngine = { | |
| ASSETS, REGIMES, ASSET_PARAMS, ASSET_DISPLAY_NAMES: { | |
| cash: "Cash", fd: "FD", gov_bonds: "Gov Bonds", nifty_50: "Nifty 50", | |
| nifty_it: "Nifty IT", real_estate: "Real Estate", crypto: "Crypto", gold: "Gold", | |
| }, | |
| TRADABLE_KEYS: ["fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"], | |
| STARTING_YEAR, STARTING_MONTH, GAME_LENGTH_MONTHS, WIN_THRESHOLD, STARTING_CASH, | |
| newGame, totalValue, investedValue, totalPnl, | |
| priceShock, randomWalk, applyAgentTrades, | |
| executePlayerTrade, advanceMonth, | |
| localAgentDecide, allLocalAgentsDecide, | |
| }; | |
| })(); | |