retro / engine.py
sankalphs's picture
feat: browser-local engine, Zerodha dashboard, historical events, chatbot, per-user isolation
f316f5a
Raw
History Blame Contribute Delete
9.35 kB
"""
Retro Alpha market simulation engine.
"""
from dataclasses import dataclass, field
from typing import Dict, List
import numpy as np
ASSETS = ["cash", "fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"]
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"
]
# Annualized expected returns and volatilities (calibrated for simulation)
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},
}
CORRELATION = 0.3
STARTING_YEAR = 1994
STARTING_MONTH = 4
GAME_LENGTH_MONTHS = 120 # 10 years
WIN_THRESHOLD = 2_000_000.0
@dataclass
class GameState:
year: int = STARTING_YEAR
month: int = STARTING_MONTH
months_elapsed: int = 0
prices: Dict[str, float] = field(default_factory=lambda: {a: 1.0 for a in ASSETS})
portfolio: Dict[str, float] = field(default_factory=lambda: {a: 0.0 for a in ASSETS})
# Total cash invested in each asset (cost basis for P&L).
cost_basis: Dict[str, float] = field(default_factory=lambda: {a: 0.0 for a in ASSETS})
# Time-series of total portfolio value for charting.
value_history: List[float] = field(default_factory=list)
# Time-series of per-asset price (keyed by asset) for the chart selector.
# Each entry is {asset_display_name: price} sampled at each advance.
price_history: List[Dict[str, float]] = field(default_factory=list)
# Last applied event headline (for AI insight context).
last_event: Dict = field(default_factory=dict)
cash_balance: float = 1_000_000.0
news: Dict = field(default_factory=dict)
agent_actions: List[Dict] = field(default_factory=list)
ledger: List[Dict] = field(default_factory=list)
game_over: bool = False
won: bool = False
def total_value(self) -> float:
return float(
self.cash_balance
+ sum(float(self.portfolio[a]) * float(self.prices[a]) for a in ASSETS)
)
def invested_value(self) -> float:
"""Total amount currently deployed in risky assets (ex-cash)."""
return float(
sum(float(self.portfolio[a]) * float(self.prices[a]) for a in ASSETS)
)
def total_pnl(self) -> float:
"""Unrealized P&L across all holdings (current value - cost basis)."""
pnl = 0.0
for a in ASSETS:
current = float(self.portfolio[a]) * float(self.prices[a])
pnl += current - float(self.cost_basis[a])
return float(pnl)
def new_game(starting_cash: float = 1_000_000.0) -> GameState:
state = GameState(cash_balance=starting_cash)
state.portfolio = {a: 0.0 for a in ASSETS}
state.cost_basis = {a: 0.0 for a in ASSETS}
state.value_history = [float(starting_cash)]
state.price_history = []
return state
def price_shock(state: GameState, impact: Dict[str, float]):
"""Apply a news-driven price shock."""
for asset in ASSETS:
if asset == "cash":
continue
if asset in impact:
state.prices[asset] = float(state.prices[asset] * (1 + float(impact[asset])))
def random_walk(state: GameState):
"""Apply monthly random price drift correlated across assets."""
tradable = [a for a in ASSETS if a != "cash"]
n = len(tradable)
corr_matrix = np.full((n, n), CORRELATION) + np.eye(n) * (1 - CORRELATION)
shocks = np.random.multivariate_normal(np.zeros(n), corr_matrix)
for i, asset in enumerate(tradable):
params = ASSET_PARAMS[asset]
monthly_mean = params["mean"] / 12
monthly_vol = params["vol"] / np.sqrt(12)
ret = float(monthly_mean + monthly_vol * shocks[i])
state.prices[asset] = float(state.prices[asset] * (1 + ret))
def apply_agent_trades(state: GameState, agent_actions: List[Dict]):
"""Apply agent trades to prices via order-flow pressure."""
pressure = {a: 0.0 for a in ASSETS}
for action in agent_actions:
for item in action.get("actions", []):
asset = item.get("asset", "cash")
if asset not in pressure:
continue
amt = float(item.get("amount_pct", 0.0)) * (1 if item.get("action") == "buy" else -1)
pressure[asset] += amt
for asset in ASSETS:
# Agent flow moves price by up to 3%
state.prices[asset] = float(state.prices[asset] * (1 + pressure[asset] * 0.03))
def execute_player_trade(state: GameState, asset: str, action: str, amount_pct: float):
"""Execute a player trade. amount_pct is relative to total portfolio value."""
if asset not in state.prices:
raise ValueError(f"Unknown asset: {asset}")
total = float(state.total_value())
trade_value = float(total * amount_pct)
if action == "buy":
trade_value = float(min(trade_value, state.cash_balance))
if trade_value <= 0:
return
price = float(state.prices[asset])
shares = float(trade_value / price) if price > 0 else 0.0
state.cash_balance = float(state.cash_balance - trade_value)
state.portfolio[asset] = float(state.portfolio[asset] + shares)
# Cost basis increases by the cash deployed.
state.cost_basis[asset] = float(state.cost_basis[asset] + trade_value)
elif action == "sell":
price = float(state.prices[asset])
current_value = float(state.portfolio[asset] * price)
sell_value = float(min(trade_value, current_value))
if sell_value <= 0:
return
shares = float(sell_value / price) if price > 0 else 0.0
# Reduce cost basis proportionally to shares sold (average-cost method).
if state.portfolio[asset] > 0:
fraction_sold = shares / state.portfolio[asset]
state.cost_basis[asset] = float(
max(0.0, state.cost_basis[asset] * (1.0 - fraction_sold))
)
state.portfolio[asset] = float(state.portfolio[asset] - shares)
state.cash_balance = float(state.cash_balance + sell_value)
state.ledger.append({
"month": state.month,
"year": state.year,
"asset": asset,
"action": action,
"amount_pct": float(amount_pct),
"value": float(trade_value),
})
def advance_month(state: GameState, news: Dict, agent_actions: List[Dict],
event: Dict = None) -> None:
"""Advance the simulation by one month.
`news` is a dict (may be empty); `event` is the historical event
dict from `events.py` and is the primary driver of price shocks.
"""
if state.game_over:
return
state.months_elapsed += 1
state.month += 1
if state.month > 12:
state.month = 1
state.year += 1
state.news = news or {}
state.agent_actions = agent_actions or []
state.last_event = event or {}
# Apply the historical event's asset impacts (the primary driver).
if event and event.get("impact"):
price_shock(state, event["impact"])
# Apply agent order-flow pressure on top of the event.
apply_agent_trades(state, agent_actions)
# Monthly correlated random walk (the baseline drift).
random_walk(state)
# Record history for the chart.
state.value_history.append(float(state.total_value()))
if len(state.value_history) > 240: # ~20 years of months, plenty
state.value_history = state.value_history[-240:]
state.price_history.append({a: float(state.prices[a]) for a in ASSETS})
if len(state.price_history) > 240:
state.price_history = state.price_history[-240:]
if state.months_elapsed >= GAME_LENGTH_MONTHS:
state.game_over = True
state.won = bool(state.total_value() >= WIN_THRESHOLD)
def year_end_summary(state: GameState) -> Dict:
"""Compute year-end stats for the mentor."""
year_ledger = [t for t in state.ledger if t["year"] == state.year]
values = state.value_history[-24:] if state.value_history else [float(state.total_value())]
returns = (
(np.diff(values) / values[:-1]).tolist()
if len(values) > 1
else [0.0]
)
sharpe = float((np.mean(returns) / (np.std(returns) + 1e-9)) * np.sqrt(12))
total = float(state.total_value())
allocations = {}
for asset in ASSETS:
val = float(state.portfolio[asset]) * float(state.prices[asset])
allocations[asset] = round(val / total, 3) if total > 0 else 0.0
return {
"year": int(state.year),
"month": int(state.month),
"starting_value": 1_000_000,
"ending_value": float(total),
"invested_value": float(state.invested_value()),
"cash": float(state.cash_balance),
"unrealized_pnl": float(state.total_pnl()),
"max_drawdown": -0.25, # placeholder
"sharpe_ratio": float(round(sharpe, 2)),
"allocations": {k: float(v) for k, v in allocations.items()},
"ledger": year_ledger,
}