Spaces:
Runtime error
Runtime error
Phase 2/3: Gradio Server backend, CRT frontend, engine, agents, mentor, tests, CI/CD
Browse files- .github/workflows/ci.yml +53 -0
- Dockerfile +25 -0
- agents.py +134 -0
- app.py +89 -0
- engine.py +159 -0
- mentor.py +36 -0
- pytest.ini +2 -0
- scripts/generate_dataset.py +0 -1
- static/app.js +165 -0
- static/index.html +108 -0
- static/style.css +361 -0
- tests/test_agents.py +24 -0
- tests/test_engine.py +44 -0
.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
|