sankalphs commited on
Commit
1d0b04b
·
1 Parent(s): 122cc3c

Phase 2/3: Gradio Server backend, CRT frontend, engine, agents, mentor, tests, CI/CD

Browse files
.github/workflows/ci.yml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: '3.11'
20
+
21
+ - name: Install dependencies
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install -r requirements.txt
25
+
26
+ - name: Lint with ruff
27
+ run: |
28
+ pip install ruff
29
+ ruff check . --exclude scripts/probe_*.py,scripts/test_*.py
30
+
31
+ - name: Run unit tests
32
+ run: |
33
+ pip install pytest
34
+ pytest tests/ -v --ignore=tests/e2e --ignore=tests/playwright
35
+
36
+ - name: Validate dataset exists
37
+ run: |
38
+ python -c "from pathlib import Path; assert Path('data/retro-alpha-final.jsonl').exists(), 'Final dataset missing'"
39
+
40
+ deploy-check:
41
+ runs-on: ubuntu-latest
42
+ needs: test
43
+ if: github.ref == 'refs/heads/main'
44
+ steps:
45
+ - name: Checkout code
46
+ uses: actions/checkout@v4
47
+
48
+ - name: Verify Space readiness
49
+ env:
50
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
51
+ run: |
52
+ pip install huggingface_hub
53
+ python -c "from huggingface_hub import HfApi; api = HfApi(token='$HF_TOKEN'); print('HF authenticated')"
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install build dependencies for llama.cpp
6
+ RUN apt-get update && apt-get install -y \
7
+ build-essential \
8
+ cmake \
9
+ git \
10
+ libopenblas-dev \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy requirements and install Python deps
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Copy application code
18
+ COPY . .
19
+
20
+ # Pre-create models directory
21
+ RUN mkdir -p /app/models
22
+
23
+ EXPOSE 7860
24
+
25
+ CMD ["python", "app.py"]
agents.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent inference using a local llama.cpp GGUF model.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Dict, List
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ ASSETS = ["cash", "fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"]
16
+ PERSONAS = ["whale", "retail", "permabull"]
17
+
18
+ # Default model path; override via MODEL_PATH env var
19
+ MODEL_PATH = os.getenv("MODEL_PATH", "models/retro-alpha-nemotron-q4_k_m.gguf")
20
+
21
+ _llm = None
22
+
23
+
24
+ def get_llm():
25
+ global _llm
26
+ if _llm is None:
27
+ try:
28
+ from llama_cpp import Llama
29
+ if not Path(MODEL_PATH).exists():
30
+ raise FileNotFoundError(f"Model not found: {MODEL_PATH}")
31
+ _llm = Llama(
32
+ model_path=MODEL_PATH,
33
+ n_ctx=2048,
34
+ n_threads=int(os.getenv("LLAMA_THREADS", "4")),
35
+ verbose=False,
36
+ )
37
+ except Exception as e:
38
+ print(f"Warning: could not load LLM: {e}. Using mock mode.")
39
+ _llm = "mock"
40
+ return _llm
41
+
42
+
43
+ def clean_text(text: str) -> str:
44
+ text = text.strip()
45
+ while "<think>" in text and "</think>" in text:
46
+ s = text.find("<think>")
47
+ e = text.find("</think>") + len("</think>")
48
+ text = text[:s] + text[e:]
49
+ return text.strip()
50
+
51
+
52
+ def generate(prompt: str, system: str = "", max_tokens: int = 256, temperature: float = 0.7) -> str:
53
+ llm = get_llm()
54
+ if llm == "mock":
55
+ return mock_generate(prompt, system)
56
+
57
+ messages = []
58
+ if system:
59
+ messages.append({"role": "system", "content": system})
60
+ messages.append({"role": "user", "content": prompt})
61
+
62
+ response = llm.create_chat_completion(
63
+ messages=messages,
64
+ max_tokens=max_tokens,
65
+ temperature=temperature,
66
+ )
67
+ return clean_text(response["choices"][0]["message"]["content"])
68
+
69
+
70
+ def mock_generate(prompt: str, system: str = "") -> str:
71
+ """Deterministic fallback when no model is loaded."""
72
+ if "agent" in prompt.lower() and "whale" in prompt.lower():
73
+ return "agent: whale\naction: buy gov_bonds 0.10\nreason: safety first\nsentiment: cautious"
74
+ if "agent" in prompt.lower() and "retail" in prompt.lower():
75
+ return "agent: retail\naction: sell nifty_it 0.10\nreason: panic selling\nsentiment: panic"
76
+ if "agent" in prompt.lower():
77
+ return "agent: permabull\naction: buy crypto 0.10\nreason: buy the dip\nsentiment: bullish"
78
+ if "headline" in prompt.lower():
79
+ return "headline: RBI holds rates steady\nimpact: cash:0 fd:0 gov_bonds:0 nifty_50:0 nifty_it:0 real_estate:0 crypto:0 gold:0\nduration: 1"
80
+ if "roast" in prompt.lower():
81
+ return "roast: diversify more\nsharpe_ratio: 0.5\nlesson: Sharpe ratio measures risk-adjusted return\nsuggestion: add bonds"
82
+ return "error: format only"
83
+
84
+
85
+ def parse_agent_response(response: str, persona: str) -> Dict:
86
+ response = clean_text(response)
87
+ try:
88
+ agent = re.search(r"agent:\s*(\w+)", response).group(1).lower()
89
+ action_match = re.search(r"action:\s*(buy|sell|hold)\s+(\w+)\s+([\d.%]+)", response)
90
+ reason = re.search(r"reason:\s*(.+)", response).group(1).strip()
91
+ sentiment = re.search(r"sentiment:\s*(\w+)", response).group(1).lower()
92
+ return {
93
+ "agent": agent or persona,
94
+ "actions": [{"asset": action_match.group(2), "action": action_match.group(1), "amount_pct": float(action_match.group(3)), "reason": reason}],
95
+ "sentiment": sentiment,
96
+ }
97
+ except Exception as e:
98
+ return {"agent": persona, "actions": [{"asset": "cash", "action": "hold", "amount_pct": 0.0, "reason": f"parse error: {e}"}], "sentiment": "neutral"}
99
+
100
+
101
+ def parse_news_response(response: str) -> Dict:
102
+ response = clean_text(response)
103
+ try:
104
+ headline = re.search(r"headline:\s*(.+)", response).group(1).strip()
105
+ impact_match = re.search(r"impact:\s*(.+?)(?:\nduration:|$)", response, re.DOTALL)
106
+ duration = int(re.search(r"duration:\s*(\d+)", response).group(1))
107
+ impact = {}
108
+ for token in impact_match.group(1).strip().split():
109
+ if ":" in token:
110
+ k, v = token.split(":")
111
+ impact[k] = float(v)
112
+ for a in ASSETS:
113
+ impact.setdefault(a, 0.0)
114
+ return {"headline": headline, "impact": impact, "duration_months": duration}
115
+ except Exception as e:
116
+ return {"headline": "Markets mixed", "impact": {a: 0.0 for a in ASSETS}, "duration_months": 1, "error": str(e)}
117
+
118
+
119
+ def decide_agent(persona: str, state: Dict) -> Dict:
120
+ system = f"You are an NPC behavior designer for an educational Indian stock-market video game. Output the {persona}'s decision in exact format:\nagent: <persona>\naction: <buy|sell|hold> <asset> <amount_pct>\nreason: <short reason>\nsentiment: <bullish|bearish|neutral|panic|cautious>"
121
+ prompt = f"Market state: {json.dumps(state)}\nPersona: {persona}"
122
+ response = generate(prompt, system=system, max_tokens=200)
123
+ return parse_agent_response(response, persona)
124
+
125
+
126
+ def generate_news(regime: str) -> Dict:
127
+ system = "You are a scenario writer for an Indian stock-market simulation game. Output exact format:\nheadline: <short headline>\nimpact: cash:<n> fd:<n> gov_bonds:<n> nifty_50:<n> nifty_it:<n> real_estate:<n> crypto:<n> gold:<n>\nduration: <months>"
128
+ prompt = f"Generate a fictional Indian financial headline for regime: {regime.replace('_', ' ').title()}."
129
+ response = generate(prompt, system=system, max_tokens=200)
130
+ return parse_news_response(response)
131
+
132
+
133
+ def all_agents_decide(state: Dict) -> List[Dict]:
134
+ return [decide_agent(p, state) for p in PERSONAS]
app.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retro Alpha — Gradio Server backend.
3
+ Serves a custom CRT terminal frontend and exposes game API endpoints.
4
+ """
5
+
6
+ import random
7
+ from pathlib import Path
8
+
9
+ from fastapi.responses import HTMLResponse
10
+ from gradio import Server
11
+
12
+ import agents
13
+ import engine
14
+ import mentor
15
+
16
+ app = Server()
17
+ ROOT = Path(__file__).resolve().parent
18
+ STATIC_DIR = ROOT / "static"
19
+
20
+ # In-memory game state (single-player)
21
+ _game_state = engine.new_game()
22
+
23
+
24
+ @app.get("/", response_class=HTMLResponse)
25
+ async def homepage():
26
+ with open(STATIC_DIR / "index.html", "r", encoding="utf-8") as f:
27
+ return f.read()
28
+
29
+
30
+ @app.api(name="state")
31
+ def get_state() -> dict:
32
+ return {
33
+ "month": _game_state.month,
34
+ "year": _game_state.year,
35
+ "prices": _game_state.prices,
36
+ "portfolio": _game_state.portfolio,
37
+ "cash": _game_state.cash_balance,
38
+ "total_value": _game_state.total_value(),
39
+ "news": _game_state.news,
40
+ "agent_actions": _game_state.agent_actions,
41
+ "game_over": _game_state.game_over,
42
+ "won": _game_state.won,
43
+ }
44
+
45
+
46
+ @app.api(name="trade")
47
+ def make_trade(asset: str, action: str, amount_pct: float) -> dict:
48
+ if _game_state.game_over:
49
+ return {"error": "Game over"}
50
+ if asset not in engine.ASSETS:
51
+ return {"error": "Invalid asset"}
52
+ engine.execute_player_trade(_game_state, asset, action, amount_pct)
53
+ return get_state()
54
+
55
+
56
+ @app.api(name="advance")
57
+ def advance_turn() -> dict:
58
+ if _game_state.game_over:
59
+ return get_state()
60
+
61
+ # Generate news
62
+ regime = random.choice(engine.REGIMES) # noqa: F821
63
+ news = agents.generate_news(regime)
64
+
65
+ # Agents decide
66
+ state_snapshot = get_state()
67
+ agent_actions = agents.all_agents_decide(state_snapshot)
68
+
69
+ # Advance
70
+ engine.advance_month(_game_state, news, agent_actions)
71
+ return get_state()
72
+
73
+
74
+ @app.api(name="mentor")
75
+ def get_mentor_review() -> dict:
76
+ summary = engine.year_end_summary(_game_state)
77
+ review = mentor.generate_review(summary)
78
+ return {"summary": summary, "review": review}
79
+
80
+
81
+ @app.api(name="reset")
82
+ def reset_game() -> dict:
83
+ global _game_state
84
+ _game_state = engine.new_game()
85
+ return get_state()
86
+
87
+
88
+ if __name__ == "__main__":
89
+ app.launch(show_error=True)
engine.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retro Alpha market simulation engine.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Dict, List
7
+
8
+ import numpy as np
9
+
10
+ ASSETS = ["cash", "fd", "gov_bonds", "nifty_50", "nifty_it", "real_estate", "crypto", "gold"]
11
+
12
+ REGIMES = [
13
+ "bull_market", "bear_market", "market_crash", "recovery", "high_inflation",
14
+ "rate_hike", "rate_cut", "election_year", "monsoon_shock", "fii_exit",
15
+ "tech_boom", "real_estate_boom", "crypto_frenzy", "gold_rush", "stagnation"
16
+ ]
17
+
18
+ # Annualized expected returns and volatilities (calibrated for simulation)
19
+ ASSET_PARAMS = {
20
+ "cash": {"mean": 0.00, "vol": 0.01},
21
+ "fd": {"mean": 0.065, "vol": 0.005},
22
+ "gov_bonds": {"mean": 0.07, "vol": 0.06},
23
+ "nifty_50": {"mean": 0.12, "vol": 0.16},
24
+ "nifty_it": {"mean": 0.15, "vol": 0.28},
25
+ "real_estate":{"mean": 0.10, "vol": 0.18},
26
+ "crypto": {"mean": 0.20, "vol": 0.65},
27
+ "gold": {"mean": 0.08, "vol": 0.14},
28
+ }
29
+
30
+ CORRELATION = 0.3
31
+
32
+
33
+ @dataclass
34
+ class GameState:
35
+ month: int = 0
36
+ year: int = 1
37
+ prices: Dict[str, float] = field(default_factory=lambda: {a: 1.0 for a in ASSETS})
38
+ portfolio: Dict[str, float] = field(default_factory=lambda: {a: 0.0 for a in ASSETS})
39
+ cash_balance: float = 1_000_000.0
40
+ news: Dict = field(default_factory=dict)
41
+ agent_actions: List[Dict] = field(default_factory=list)
42
+ ledger: List[Dict] = field(default_factory=list)
43
+ game_over: bool = False
44
+ won: bool = False
45
+
46
+ def total_value(self) -> float:
47
+ return self.cash_balance + sum(self.portfolio[a] * self.prices[a] for a in ASSETS)
48
+
49
+
50
+ def new_game(starting_cash: float = 1_000_000.0) -> GameState:
51
+ state = GameState(cash_balance=starting_cash)
52
+ state.portfolio = {a: 0.0 for a in ASSETS}
53
+ return state
54
+
55
+
56
+ def price_shock(state: GameState, impact: Dict[str, float]):
57
+ """Apply a news-driven price shock."""
58
+ for asset in ASSETS:
59
+ if asset in impact:
60
+ state.prices[asset] *= (1 + impact[asset])
61
+
62
+
63
+ def random_walk(state: GameState):
64
+ """Apply monthly random price drift correlated across assets."""
65
+ n = len(ASSETS)
66
+ corr_matrix = np.full((n, n), CORRELATION) + np.eye(n) * (1 - CORRELATION)
67
+ shocks = np.random.multivariate_normal(np.zeros(n), corr_matrix)
68
+ for i, asset in enumerate(ASSETS):
69
+ params = ASSET_PARAMS[asset]
70
+ monthly_mean = params["mean"] / 12
71
+ monthly_vol = params["vol"] / np.sqrt(12)
72
+ ret = monthly_mean + monthly_vol * shocks[i]
73
+ state.prices[asset] *= (1 + ret)
74
+
75
+
76
+ def apply_agent_trades(state: GameState, agent_actions: List[Dict]):
77
+ """Apply agent trades to prices via order-flow pressure."""
78
+ pressure = {a: 0.0 for a in ASSETS}
79
+ for action in agent_actions:
80
+ for item in action.get("actions", []):
81
+ asset = item["asset"]
82
+ amt = item["amount_pct"] * (1 if item["action"] == "buy" else -1)
83
+ pressure[asset] += amt
84
+ for asset in ASSETS:
85
+ # Agent flow moves price by up to 3%
86
+ state.prices[asset] *= (1 + pressure[asset] * 0.03)
87
+
88
+
89
+ def execute_player_trade(state: GameState, asset: str, action: str, amount_pct: float):
90
+ """Execute a player trade. amount_pct is relative to total portfolio value."""
91
+ total = state.total_value()
92
+ trade_value = total * amount_pct
93
+
94
+ if action == "buy":
95
+ if state.cash_balance < trade_value:
96
+ trade_value = state.cash_balance
97
+ shares = trade_value / state.prices[asset]
98
+ state.cash_balance -= trade_value
99
+ state.portfolio[asset] += shares
100
+ elif action == "sell":
101
+ current_value = state.portfolio[asset] * state.prices[asset]
102
+ sell_value = min(trade_value, current_value)
103
+ shares = sell_value / state.prices[asset]
104
+ state.portfolio[asset] -= shares
105
+ state.cash_balance += sell_value
106
+
107
+ state.ledger.append({
108
+ "month": state.month,
109
+ "year": state.year,
110
+ "asset": asset,
111
+ "action": action,
112
+ "amount_pct": amount_pct,
113
+ "value": trade_value,
114
+ })
115
+
116
+
117
+ def advance_month(state: GameState, news: Dict, agent_actions: List[Dict]):
118
+ """Advance the simulation by one month."""
119
+ state.month += 1
120
+ if state.month > 12:
121
+ state.month = 1
122
+ state.year += 1
123
+
124
+ state.news = news
125
+ state.agent_actions = agent_actions
126
+
127
+ if news.get("impact"):
128
+ price_shock(state, news["impact"])
129
+
130
+ apply_agent_trades(state, agent_actions)
131
+ random_walk(state)
132
+
133
+ if state.year > 10:
134
+ state.game_over = True
135
+ state.won = state.total_value() >= 1_000_000
136
+
137
+
138
+ def year_end_summary(state: GameState) -> Dict:
139
+ """Compute year-end stats for the mentor."""
140
+ year_ledger = [t for t in state.ledger if t["year"] == state.year]
141
+ values = [state.total_value()] # simplified
142
+ returns = np.diff(values) / values[:-1] if len(values) > 1 else [0.0]
143
+ sharpe = (np.mean(returns) / (np.std(returns) + 1e-9)) * np.sqrt(12)
144
+
145
+ total = state.total_value()
146
+ allocations = {}
147
+ for asset in ASSETS:
148
+ val = state.portfolio[asset] * state.prices[asset]
149
+ allocations[asset] = round(val / total, 3) if total > 0 else 0.0
150
+
151
+ return {
152
+ "year": state.year,
153
+ "starting_value": 1_000_000,
154
+ "ending_value": total,
155
+ "max_drawdown": -0.25, # placeholder
156
+ "sharpe_ratio": round(sharpe, 2),
157
+ "allocations": allocations,
158
+ "ledger": year_ledger,
159
+ }
mentor.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sharpe Ratio Mentor — year-end review generator."""
2
+
3
+ import json
4
+ import re
5
+
6
+ from agents import generate
7
+
8
+
9
+ def parse_mentor_response(response: str) -> dict:
10
+ response = response.strip()
11
+ try:
12
+ roast = re.search(r"roast:\s*(.+)", response).group(1).strip()
13
+ sharpe = float(re.search(r"sharpe_ratio:\s*([-\d.]+)", response).group(1))
14
+ lesson = re.search(r"lesson:\s*(.+)", response).group(1).strip()
15
+ suggestion = re.search(r"suggestion:\s*(.+)", response).group(1).strip()
16
+ return {"roast": roast, "sharpe_ratio": sharpe, "lesson": lesson, "suggestion": suggestion}
17
+ except Exception as e:
18
+ return {
19
+ "roast": "Could not parse review.",
20
+ "sharpe_ratio": 0.0,
21
+ "lesson": f"Parse error: {e}",
22
+ "suggestion": "Try again next year.",
23
+ }
24
+
25
+
26
+ def generate_review(summary: dict) -> dict:
27
+ system = "You are a sarcastic but caring Indian finance professor in a video game. Output a year-end review in exact format:\nroast: <witty roast, under 60 chars>\nsharpe_ratio: <number>\nlesson: <explain Sharpe ratio simply, under 100 chars>\nsuggestion: <one concrete tip, under 60 chars>"
28
+ prompt = (
29
+ f"Starting value: ₹{summary['starting_value']:,}. "
30
+ f"Ending value: ₹{summary['ending_value']:,.0f}. "
31
+ f"Max drawdown: {summary['max_drawdown']*100:.0f}%. "
32
+ f"Allocation: {json.dumps(summary['allocations'])}. "
33
+ f"Sharpe ratio: {summary['sharpe_ratio']}."
34
+ )
35
+ response = generate(prompt, system=system, max_tokens=250)
36
+ return parse_mentor_response(response)
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
scripts/generate_dataset.py CHANGED
@@ -10,7 +10,6 @@ import os
10
  import random
11
  import time
12
  from pathlib import Path
13
- from typing import Any
14
 
15
  import aiohttp
16
  from dotenv import load_dotenv
 
10
  import random
11
  import time
12
  from pathlib import Path
 
13
 
14
  import aiohttp
15
  from dotenv import load_dotenv
static/app.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API = {
2
+ async call(name, data = {}) {
3
+ const client = await window.gradioClient || (window.gradioClient = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js').then(m => m.Client.connect(window.location.origin)));
4
+ return await client.predict(`/${name}`, data);
5
+ }
6
+ };
7
+
8
+ const ASSETS = ['cash', 'fd', 'gov_bonds', 'nifty_50', 'nifty_it', 'real_estate', 'crypto', 'gold'];
9
+ const ASSET_LABELS = {
10
+ cash: 'Cash (INR)',
11
+ fd: 'Bank FD',
12
+ gov_bonds: 'Gov Bonds',
13
+ nifty_50: 'Nifty 50',
14
+ nifty_it: 'Nifty IT',
15
+ real_estate: 'Real Estate',
16
+ crypto: 'Crypto',
17
+ gold: 'Gold'
18
+ };
19
+
20
+ let history = [];
21
+
22
+ function formatMoney(n) {
23
+ return '₹' + Math.round(n).toLocaleString('en-IN');
24
+ }
25
+
26
+ function formatPrice(n) {
27
+ return n.toFixed(3);
28
+ }
29
+
30
+ function updateClock() {
31
+ const now = new Date();
32
+ document.getElementById('clock').textContent = now.toLocaleTimeString('en-IN');
33
+ }
34
+ setInterval(updateClock, 1000);
35
+ updateClock();
36
+
37
+ async function fetchState() {
38
+ const result = await API.call('state');
39
+ return result.data;
40
+ }
41
+
42
+ function renderState(state) {
43
+ document.getElementById('total-value').textContent = formatMoney(state.total_value);
44
+ document.getElementById('game-time').textContent = `YEAR ${state.year} / MONTH ${state.month}`;
45
+
46
+ // Ticker
47
+ const tickerItems = ASSETS.map(a => `${ASSET_LABELS[a]}: ${formatPrice(state.prices[a])}`).join(' ');
48
+ document.getElementById('ticker').textContent = tickerItems;
49
+
50
+ // News
51
+ const newsEl = document.getElementById('news-display');
52
+ if (state.news && state.news.headline) {
53
+ newsEl.innerHTML = `<strong>${state.news.headline}</strong><br><br>${formatImpact(state.news.impact)}`;
54
+ }
55
+
56
+ // Holdings
57
+ const tbody = document.querySelector('#holdings-table tbody');
58
+ tbody.innerHTML = '';
59
+ ASSETS.forEach(asset => {
60
+ const price = state.prices[asset];
61
+ const qty = state.portfolio[asset];
62
+ const value = qty * price;
63
+ const row = document.createElement('tr');
64
+ row.innerHTML = `<td>${ASSET_LABELS[asset]}</td><td>${formatPrice(price)}</td><td>${qty.toFixed(2)}</td><td>${formatMoney(value)}</td>`;
65
+ tbody.appendChild(row);
66
+ });
67
+
68
+ // Agent log
69
+ const logEl = document.getElementById('agent-log');
70
+ logEl.innerHTML = '';
71
+ (state.agent_actions || []).forEach(action => {
72
+ const div = document.createElement('div');
73
+ div.className = `agent-entry agent-${action.agent}`;
74
+ const acts = (action.actions || []).map(a => `${a.action.toUpperCase()} ${ASSET_LABELS[a.asset]} ${(a.amount_pct * 100).toFixed(0)}%`).join(', ');
75
+ div.innerHTML = `<strong>${action.agent.toUpperCase()}</strong> [${action.sentiment}]<br>${acts}<br><em>${action.actions[0]?.reason || ''}</em>`;
76
+ logEl.appendChild(div);
77
+ });
78
+
79
+ // Chart
80
+ history.push(state.total_value);
81
+ if (history.length > 50) history.shift();
82
+ drawChart(history);
83
+ }
84
+
85
+ function formatImpact(impact) {
86
+ if (!impact) return '';
87
+ return ASSETS.map(a => `${ASSET_LABELS[a]}: ${(impact[a] * 100).toFixed(1)}%`).join(' | ');
88
+ }
89
+
90
+ function drawChart(data) {
91
+ const canvas = document.getElementById('chart');
92
+ const ctx = canvas.getContext('2d');
93
+ const w = canvas.width;
94
+ const h = canvas.height;
95
+ ctx.clearRect(0, 0, w, h);
96
+ if (data.length < 2) return;
97
+
98
+ const min = Math.min(...data);
99
+ const max = Math.max(...data);
100
+ const range = max - min || 1;
101
+
102
+ ctx.strokeStyle = '#33ff33';
103
+ ctx.lineWidth = 2;
104
+ ctx.beginPath();
105
+ data.forEach((v, i) => {
106
+ const x = (i / (data.length - 1)) * (w - 20) + 10;
107
+ const y = h - 10 - ((v - min) / range) * (h - 20);
108
+ if (i === 0) ctx.moveTo(x, y);
109
+ else ctx.lineTo(x, y);
110
+ });
111
+ ctx.stroke();
112
+ }
113
+
114
+ async function init() {
115
+ // Populate asset select
116
+ const select = document.getElementById('trade-asset');
117
+ ASSETS.forEach(a => {
118
+ const opt = document.createElement('option');
119
+ opt.value = a;
120
+ opt.textContent = ASSET_LABELS[a];
121
+ select.appendChild(opt);
122
+ });
123
+
124
+ // Load initial state
125
+ const state = await fetchState();
126
+ renderState(state);
127
+
128
+ // Event listeners
129
+ document.getElementById('btn-trade').addEventListener('click', async () => {
130
+ const asset = document.getElementById('trade-asset').value;
131
+ const action = document.getElementById('trade-action').value;
132
+ const amount = parseFloat(document.getElementById('trade-amount').value) / 100;
133
+ const result = await API.call('trade', { asset, action, amount_pct: amount });
134
+ renderState(result.data);
135
+ });
136
+
137
+ document.getElementById('btn-advance').addEventListener('click', async () => {
138
+ const result = await API.call('advance');
139
+ renderState(result.data);
140
+ if (result.data.month === 0 || result.data.month === 12) {
141
+ showMentor();
142
+ }
143
+ });
144
+
145
+ document.getElementById('btn-reset').addEventListener('click', async () => {
146
+ history = [];
147
+ const result = await API.call('reset');
148
+ renderState(result.data);
149
+ });
150
+
151
+ document.getElementById('btn-close-mentor').addEventListener('click', () => {
152
+ document.getElementById('mentor-modal').classList.add('hidden');
153
+ });
154
+ }
155
+
156
+ async function showMentor() {
157
+ const result = await API.call('mentor');
158
+ const review = result.data.review;
159
+ document.getElementById('mentor-roast').textContent = review.roast;
160
+ document.getElementById('mentor-lesson').textContent = review.lesson;
161
+ document.getElementById('mentor-suggestion').textContent = review.suggestion;
162
+ document.getElementById('mentor-modal').classList.remove('hidden');
163
+ }
164
+
165
+ init().catch(console.error);
static/index.html ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Retro Alpha — Agentic Trading Terminal</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=VT323&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ </head>
12
+ <body>
13
+ <div class="crt-frame">
14
+ <div class="crt-screen">
15
+ <div class="scanlines"></div>
16
+ <div class="flicker"></div>
17
+ <div class="screen-curve"></div>
18
+
19
+ <header class="terminal-header">
20
+ <div class="logo">
21
+ <span class="blink">[</span> RETRO ALPHA <span class="blink">]</span>
22
+ </div>
23
+ <div class="status-bar">
24
+ <span id="clock">--:--:--</span>
25
+ <span class="sep">|</span>
26
+ <span>INR TERMINAL</span>
27
+ <span class="sep">|</span>
28
+ <span id="connection" class="online">ONLINE</span>
29
+ </div>
30
+ </header>
31
+
32
+ <div class="ticker-wrap">
33
+ <div class="ticker" id="ticker">
34
+ Loading market data...
35
+ </div>
36
+ </div>
37
+
38
+ <main class="terminal-grid">
39
+ <section class="panel news-panel">
40
+ <h2>:: BREAKING_NEWS</h2>
41
+ <div id="news-display" class="news-content">
42
+ Awaiting first broadcast...
43
+ </div>
44
+ </section>
45
+
46
+ <section class="panel chart-panel">
47
+ <h2>:: PORTFOLIO_VALUE</h2>
48
+ <div class="big-number" id="total-value">₹0</div>
49
+ <div class="sub-line" id="game-time">YEAR 1 / MONTH 0</div>
50
+ <canvas id="chart" width="400" height="180"></canvas>
51
+ </section>
52
+
53
+ <section class="panel holdings-panel">
54
+ <h2>:: HOLDINGS</h2>
55
+ <table id="holdings-table">
56
+ <thead>
57
+ <tr><th>ASSET</th><th>PRICE</th><th>QTY</th><th>VALUE</th></tr>
58
+ </thead>
59
+ <tbody></tbody>
60
+ </table>
61
+ </section>
62
+
63
+ <section class="panel agents-panel">
64
+ <h2>:: AGENT_ACTIVITY</h2>
65
+ <div id="agent-log" class="agent-log"></div>
66
+ </section>
67
+
68
+ <section class="panel trade-panel">
69
+ <h2>:: ORDER_PAD</h2>
70
+ <div class="trade-form">
71
+ <label>ACTION</label>
72
+ <select id="trade-action">
73
+ <option value="buy">BUY</option>
74
+ <option value="sell">SELL</option>
75
+ </select>
76
+ <label>ASSET</label>
77
+ <select id="trade-asset"></select>
78
+ <label>AMOUNT (%)</label>
79
+ <input type="number" id="trade-amount" min="1" max="100" value="10">
80
+ <button id="btn-trade" class="btn-primary">EXECUTE</button>
81
+ <button id="btn-advance" class="btn-secondary">ADVANCE MONTH</button>
82
+ <button id="btn-reset" class="btn-danger">RESET</button>
83
+ </div>
84
+ </section>
85
+ </main>
86
+
87
+ <div id="mentor-modal" class="mentor-modal hidden">
88
+ <div class="modal-content">
89
+ <h2>:: YEAR_END_REVIEW</h2>
90
+ <div id="mentor-roast" class="roast"></div>
91
+ <div id="mentor-lesson" class="lesson"></div>
92
+ <div id="mentor-suggestion" class="suggestion"></div>
93
+ <button id="btn-close-mentor" class="btn-primary">CONTINUE</button>
94
+ </div>
95
+ </div>
96
+
97
+ <footer class="terminal-footer">
98
+ <span>RETRO_ALPHA v0.9.0</span>
99
+ <span class="sep">|</span>
100
+ <span>NEMOTRON-3-NANO-4B LOCAL</span>
101
+ <span class="sep">|</span>
102
+ <span>BUILT FOR HF BUILD SMALL HACKATHON</span>
103
+ </footer>
104
+ </div>
105
+ </div>
106
+ <script src="/static/app.js"></script>
107
+ </body>
108
+ </html>
static/style.css ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --phosphor: #33ff33;
3
+ --phosphor-dim: #1a991a;
4
+ --bg: #050a05;
5
+ --panel-bg: rgba(10, 25, 10, 0.85);
6
+ --danger: #ff3333;
7
+ --warn: #ffcc00;
8
+ --cyan: #33ffff;
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ html, body {
16
+ margin: 0;
17
+ padding: 0;
18
+ height: 100%;
19
+ background: #111;
20
+ font-family: 'Share Tech Mono', 'VT323', monospace;
21
+ color: var(--phosphor);
22
+ overflow: hidden;
23
+ }
24
+
25
+ .crt-frame {
26
+ width: 100vw;
27
+ height: 100vh;
28
+ padding: 2vh 2vw;
29
+ background: radial-gradient(circle at center, #1a1a1a 0%, #000 100%);
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ }
34
+
35
+ .crt-screen {
36
+ position: relative;
37
+ width: 96vw;
38
+ height: 96vh;
39
+ background: var(--bg);
40
+ border-radius: 40px / 30px;
41
+ box-shadow:
42
+ inset 0 0 80px rgba(0, 0, 0, 0.9),
43
+ 0 0 20px rgba(51, 255, 51, 0.1),
44
+ inset 0 0 20px rgba(51, 255, 51, 0.05);
45
+ overflow: hidden;
46
+ padding: 24px;
47
+ display: flex;
48
+ flex-direction: column;
49
+ }
50
+
51
+ .scanlines {
52
+ position: absolute;
53
+ inset: 0;
54
+ background: repeating-linear-gradient(
55
+ to bottom,
56
+ rgba(0, 0, 0, 0) 0px,
57
+ rgba(0, 0, 0, 0) 2px,
58
+ rgba(0, 0, 0, 0.25) 3px,
59
+ rgba(0, 0, 0, 0.25) 4px
60
+ );
61
+ pointer-events: none;
62
+ z-index: 10;
63
+ }
64
+
65
+ .flicker {
66
+ position: absolute;
67
+ inset: 0;
68
+ background: rgba(51, 255, 51, 0.02);
69
+ opacity: 0;
70
+ animation: flicker 0.15s infinite;
71
+ pointer-events: none;
72
+ z-index: 11;
73
+ }
74
+
75
+ .screen-curve {
76
+ position: absolute;
77
+ inset: 0;
78
+ border-radius: 40px / 30px;
79
+ box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.8);
80
+ pointer-events: none;
81
+ z-index: 12;
82
+ }
83
+
84
+ @keyframes flicker {
85
+ 0% { opacity: 0.02; }
86
+ 50% { opacity: 0.05; }
87
+ 100% { opacity: 0.02; }
88
+ }
89
+
90
+ .terminal-header {
91
+ display: flex;
92
+ justify-content: space-between;
93
+ align-items: center;
94
+ border-bottom: 2px solid var(--phosphor-dim);
95
+ padding-bottom: 12px;
96
+ margin-bottom: 12px;
97
+ text-shadow: 0 0 8px var(--phosphor);
98
+ }
99
+
100
+ .logo {
101
+ font-family: 'VT323', monospace;
102
+ font-size: 2rem;
103
+ letter-spacing: 2px;
104
+ }
105
+
106
+ .status-bar {
107
+ font-size: 0.9rem;
108
+ color: var(--phosphor-dim);
109
+ }
110
+
111
+ .sep {
112
+ margin: 0 8px;
113
+ color: var(--phosphor-dim);
114
+ }
115
+
116
+ .online {
117
+ color: var(--phosphor);
118
+ animation: pulse 1.5s infinite;
119
+ }
120
+
121
+ @keyframes pulse {
122
+ 0%, 100% { opacity: 1; }
123
+ 50% { opacity: 0.5; }
124
+ }
125
+
126
+ .blink {
127
+ animation: blink 1s step-end infinite;
128
+ }
129
+
130
+ @keyframes blink {
131
+ 50% { opacity: 0; }
132
+ }
133
+
134
+ .ticker-wrap {
135
+ background: rgba(0, 20, 0, 0.6);
136
+ border: 1px solid var(--phosphor-dim);
137
+ padding: 6px 0;
138
+ overflow: hidden;
139
+ white-space: nowrap;
140
+ margin-bottom: 16px;
141
+ }
142
+
143
+ .ticker {
144
+ display: inline-block;
145
+ animation: ticker 30s linear infinite;
146
+ padding-left: 100%;
147
+ }
148
+
149
+ @keyframes ticker {
150
+ 0% { transform: translateX(0); }
151
+ 100% { transform: translateX(-100%); }
152
+ }
153
+
154
+ .terminal-grid {
155
+ flex: 1;
156
+ display: grid;
157
+ grid-template-columns: 1.2fr 1fr 1fr;
158
+ grid-template-rows: 1fr 1fr;
159
+ gap: 16px;
160
+ overflow-y: auto;
161
+ }
162
+
163
+ .panel {
164
+ background: var(--panel-bg);
165
+ border: 1px solid var(--phosphor-dim);
166
+ padding: 14px;
167
+ position: relative;
168
+ overflow: hidden;
169
+ }
170
+
171
+ .panel::before {
172
+ content: '';
173
+ position: absolute;
174
+ top: 0;
175
+ left: 0;
176
+ right: 0;
177
+ height: 2px;
178
+ background: linear-gradient(90deg, transparent, var(--phosphor), transparent);
179
+ opacity: 0.5;
180
+ }
181
+
182
+ .panel h2 {
183
+ margin: 0 0 12px 0;
184
+ font-size: 1rem;
185
+ color: var(--cyan);
186
+ text-shadow: 0 0 5px var(--cyan);
187
+ border-bottom: 1px dashed var(--phosphor-dim);
188
+ padding-bottom: 6px;
189
+ }
190
+
191
+ .news-panel {
192
+ grid-row: span 2;
193
+ }
194
+
195
+ .news-content {
196
+ font-size: 1.1rem;
197
+ line-height: 1.5;
198
+ color: var(--phosphor);
199
+ }
200
+
201
+ .big-number {
202
+ font-size: 2.4rem;
203
+ font-weight: bold;
204
+ color: var(--phosphor);
205
+ text-shadow: 0 0 12px var(--phosphor);
206
+ }
207
+
208
+ .sub-line {
209
+ font-size: 0.9rem;
210
+ color: var(--phosphor-dim);
211
+ margin-bottom: 12px;
212
+ }
213
+
214
+ #chart {
215
+ width: 100%;
216
+ height: 130px;
217
+ background: rgba(0, 10, 0, 0.5);
218
+ border: 1px solid var(--phosphor-dim);
219
+ }
220
+
221
+ table {
222
+ width: 100%;
223
+ border-collapse: collapse;
224
+ font-size: 0.85rem;
225
+ }
226
+
227
+ th, td {
228
+ text-align: left;
229
+ padding: 4px 6px;
230
+ border-bottom: 1px solid rgba(51, 255, 51, 0.2);
231
+ }
232
+
233
+ th {
234
+ color: var(--cyan);
235
+ }
236
+
237
+ .agent-log {
238
+ font-size: 0.85rem;
239
+ line-height: 1.4;
240
+ max-height: 100%;
241
+ overflow-y: auto;
242
+ }
243
+
244
+ .agent-entry {
245
+ margin-bottom: 10px;
246
+ padding-left: 8px;
247
+ border-left: 2px solid var(--phosphor-dim);
248
+ }
249
+
250
+ .agent-whale { border-color: #33ff33; }
251
+ .agent-retail { border-color: #ffcc00; }
252
+ .agent-permabull { border-color: #ff3333; }
253
+
254
+ .trade-form {
255
+ display: grid;
256
+ grid-template-columns: 1fr 2fr;
257
+ gap: 10px;
258
+ align-items: center;
259
+ }
260
+
261
+ .trade-form label {
262
+ font-size: 0.85rem;
263
+ color: var(--cyan);
264
+ }
265
+
266
+ .trade-form select,
267
+ .trade-form input {
268
+ background: rgba(0, 20, 0, 0.8);
269
+ border: 1px solid var(--phosphor-dim);
270
+ color: var(--phosphor);
271
+ padding: 6px;
272
+ font-family: inherit;
273
+ }
274
+
275
+ .trade-form button {
276
+ grid-column: span 2;
277
+ padding: 10px;
278
+ background: rgba(0, 40, 0, 0.8);
279
+ border: 1px solid var(--phosphor);
280
+ color: var(--phosphor);
281
+ font-family: inherit;
282
+ cursor: pointer;
283
+ text-transform: uppercase;
284
+ transition: all 0.2s;
285
+ }
286
+
287
+ .trade-form button:hover {
288
+ background: var(--phosphor);
289
+ color: #000;
290
+ box-shadow: 0 0 15px var(--phosphor);
291
+ }
292
+
293
+ .btn-danger {
294
+ border-color: var(--danger) !important;
295
+ color: var(--danger) !important;
296
+ }
297
+
298
+ .btn-danger:hover {
299
+ background: var(--danger) !important;
300
+ color: #000 !important;
301
+ }
302
+
303
+ .mentor-modal {
304
+ position: fixed;
305
+ inset: 0;
306
+ background: rgba(0, 0, 0, 0.85);
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ z-index: 100;
311
+ }
312
+
313
+ .mentor-modal.hidden {
314
+ display: none;
315
+ }
316
+
317
+ .modal-content {
318
+ background: var(--bg);
319
+ border: 2px solid var(--phosphor);
320
+ padding: 32px;
321
+ max-width: 600px;
322
+ width: 90%;
323
+ box-shadow: 0 0 40px rgba(51, 255, 51, 0.3);
324
+ }
325
+
326
+ .roast {
327
+ font-size: 1.3rem;
328
+ color: var(--warn);
329
+ margin-bottom: 16px;
330
+ text-shadow: 0 0 8px var(--warn);
331
+ }
332
+
333
+ .lesson {
334
+ font-size: 1rem;
335
+ color: var(--phosphor);
336
+ margin-bottom: 12px;
337
+ line-height: 1.5;
338
+ }
339
+
340
+ .suggestion {
341
+ font-size: 1rem;
342
+ color: var(--cyan);
343
+ margin-bottom: 20px;
344
+ }
345
+
346
+ .terminal-footer {
347
+ margin-top: 12px;
348
+ padding-top: 8px;
349
+ border-top: 1px solid var(--phosphor-dim);
350
+ font-size: 0.75rem;
351
+ color: var(--phosphor-dim);
352
+ text-align: center;
353
+ }
354
+
355
+ @media (max-width: 900px) {
356
+ .terminal-grid {
357
+ grid-template-columns: 1fr;
358
+ grid-template-rows: auto;
359
+ }
360
+ .news-panel { grid-row: span 1; }
361
+ }
tests/test_agents.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for agent inference helpers."""
2
+
3
+ import agents
4
+
5
+
6
+ def test_parse_agent_response():
7
+ response = "agent: whale\naction: buy gov_bonds 0.15\nreason: safety\nsentiment: cautious"
8
+ parsed = agents.parse_agent_response(response, "whale")
9
+ assert parsed["agent"] == "whale"
10
+ assert parsed["actions"][0]["asset"] == "gov_bonds"
11
+ assert parsed["actions"][0]["amount_pct"] == 0.15
12
+
13
+
14
+ def test_parse_news_response():
15
+ response = "headline: RBI hikes\nimpact: cash:0 fd:0.1 gov_bonds:-0.05 nifty_50:-0.05 nifty_it:-0.05 real_estate:-0.05 crypto:-0.05 gold:0.05\nduration: 3"
16
+ parsed = agents.parse_news_response(response)
17
+ assert parsed["headline"] == "RBI hikes"
18
+ assert "cash" in parsed["impact"]
19
+ assert parsed["duration_months"] == 3
20
+
21
+
22
+ def test_mock_generate():
23
+ result = agents.mock_generate("agent whale", "")
24
+ assert "agent:" in result
tests/test_engine.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the Retro Alpha engine."""
2
+
3
+ import pytest
4
+
5
+ import engine
6
+
7
+
8
+ def test_new_game():
9
+ state = engine.new_game()
10
+ assert state.cash_balance == 1_000_000
11
+ assert state.total_value() == 1_000_000
12
+ assert all(state.portfolio[a] == 0.0 for a in engine.ASSETS)
13
+
14
+
15
+ def test_trade_buy():
16
+ state = engine.new_game()
17
+ engine.execute_player_trade(state, "nifty_50", "buy", 0.5)
18
+ assert state.cash_balance < 1_000_000
19
+ assert state.portfolio["nifty_50"] > 0
20
+ assert len(state.ledger) == 1
21
+
22
+
23
+ def test_trade_sell():
24
+ state = engine.new_game()
25
+ engine.execute_player_trade(state, "nifty_50", "buy", 0.5)
26
+ engine.execute_player_trade(state, "nifty_50", "sell", 0.5)
27
+ assert state.cash_balance > 0
28
+
29
+
30
+ def test_advance_month():
31
+ state = engine.new_game()
32
+ news = {"headline": "Test", "impact": {a: 0.0 for a in engine.ASSETS}, "duration_months": 1}
33
+ engine.advance_month(state, news, [])
34
+ assert state.month == 1
35
+ assert state.year == 1
36
+
37
+
38
+ def test_game_over():
39
+ state = engine.new_game()
40
+ state.year = 11
41
+ state.month = 1
42
+ news = {"headline": "Test", "impact": {a: 0.0 for a in engine.ASSETS}, "duration_months": 1}
43
+ engine.advance_month(state, news, [])
44
+ assert state.game_over