diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml
index 944aec2..0cc241a 100644
--- a/.github/workflows/backend-tests.yml
+++ b/.github/workflows/backend-tests.yml
@@ -53,6 +53,7 @@ jobs:
- name: Test
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db
+ PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }}
run: pytest -v --cov=app --cov-report=xml
- name: Upload coverage
diff --git a/backend/app/agents/scheduler.py b/backend/app/agents/scheduler.py
index 8231454..7bef360 100644
--- a/backend/app/agents/scheduler.py
+++ b/backend/app/agents/scheduler.py
@@ -658,6 +658,8 @@ async def _update_tournament_rankings_async() -> Dict[str, Any]:
}
+
+
# ============================================================================
# CRASH RECOVERY TASKS
# ============================================================================
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index 7d690f5..55f13c8 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -5,21 +5,21 @@
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
ALGORITHM = "HS256"
-def get_current_user(authorization: str = Header(...)) -> dict:
+def get_current_user(authorization: str = Header(None)) -> dict:
"""Verify JWT token"""
- if not authorization.startswith("Bearer "):
- raise HTTPException(401, "Invalid authorization header")
-
+ if not authorization or not authorization.startswith("Bearer "):
+ raise HTTPException(401, "Missing or invalid authorization header")
+
token = authorization.split(" ")[1]
-
+
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_address: str = payload.get("sub")
role: str = payload.get("role")
-
+
if user_address is None:
raise HTTPException(401, "Invalid token")
-
+
return {"address": user_address, "role": role}
except JWTError:
raise HTTPException(401, "Invalid token")
@@ -27,4 +27,4 @@ def get_current_user(authorization: str = Header(...)) -> dict:
def require_admin(user: dict = Depends(get_current_user)) -> dict:
if user.get("role") != "admin":
raise HTTPException(403, "Admin access required")
- return user
\ No newline at end of file
+ return user
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index 50f63e0..2dce4d3 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -4,10 +4,9 @@
try:
from .routers import tournament, agent, agent_state, trade, bet, auth, market_data
except ImportError:
- from routers import tournament, agent, agent_state, trade, bet, auth, market_data
+ from app.api.routers import tournament, agent, agent_state, trade, bet, auth, market_data
from os import getenv
import uvicorn
-from contextlib import asynccontextmanager
APPLICATION_PORT = 8000
@@ -26,7 +25,7 @@
allow_headers=["*"], # Allow all headers
)
-# Register routers
+# Register Routers
app.include_router(tournament.router, prefix="/tournaments", tags=["Tournaments"])
app.include_router(agent.router, prefix="/agents", tags=["Agents"])
app.include_router(agent_state.router, prefix="/agent-states", tags=["Agent States"])
@@ -38,7 +37,7 @@
@app.get("/")
def root():
- return {"message": "Agonus API running 🚀"}
+ return {"message": "Agonus API running"}
if __name__ == "__main__":
diff --git a/backend/app/api/routers/agent.py b/backend/app/api/routers/agent.py
index 59d1e32..24bc92b 100644
--- a/backend/app/api/routers/agent.py
+++ b/backend/app/api/routers/agent.py
@@ -36,7 +36,6 @@ async def get_agent_stats(agent_id: UUID, session: AsyncSession = Depends(get_db
if not agent:
raise HTTPException(status_code=404, detail="Agent Not Found")
- # Return the stats JSON field
return agent.stats
@@ -47,14 +46,12 @@ async def create_agent(
admin: dict = Depends(require_admin),
):
"""POST route for creating a new agent (admin only)"""
- # Create Agent model from schema, excluding None values to use model defaults
agent_dict = agent_data.model_dump(exclude_none=True)
- # Ensure stats and memory have default values if not provided
- if 'stats' not in agent_dict:
- agent_dict['stats'] = {}
- if 'memory' not in agent_dict:
- agent_dict['memory'] = {}
+ if "stats" not in agent_dict:
+ agent_dict["stats"] = {}
+ if "memory" not in agent_dict:
+ agent_dict["memory"] = {}
agent = Agent(**agent_dict)
@@ -76,7 +73,6 @@ async def update_agent(
if not db_agent:
raise HTTPException(status_code=404, detail="Agent Not Found")
- # Update only provided fields
update_data = agent_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
@@ -101,7 +97,6 @@ async def update_agent_stats(
if not agent:
raise HTTPException(status_code=404, detail="Agent Not Found")
- # Merge new stats with existing stats
agent.stats = {**agent.stats, **stats}
session.add(agent)
@@ -123,7 +118,6 @@ async def update_agent_memory(
if not agent:
raise HTTPException(status_code=404, detail="Agent Not Found")
- # Merge new memory with existing memory
agent.memory = {**agent.memory, **memory}
session.add(agent)
@@ -139,15 +133,12 @@ async def delete_agent(
session: AsyncSession = Depends(get_db),
admin: dict = Depends(require_admin),
):
- """DELETE route for deleting an agent (admin only - use with caution)"""
+ """DELETE route for deleting an agent (admin only)"""
agent = await session.get(Agent, agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent Not Found")
- # Note: This will fail if agent has related trades/bets due to foreign keys
- # Consider soft delete instead (add 'active' boolean field)
await session.delete(agent)
await session.commit()
return {"message": f"Agent {agent_id} deleted successfully"}
-
diff --git a/backend/app/api/routers/auth.py b/backend/app/api/routers/auth.py
index f7499e9..f97d9af 100644
--- a/backend/app/api/routers/auth.py
+++ b/backend/app/api/routers/auth.py
@@ -11,37 +11,56 @@
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
ALGORITHM = "HS256"
+# Load admin addresses from env
raw_addresses = os.getenv("ADMIN_ADDRESSES", "")
-ADMIN_ADDRESSES = set(addr.strip().lower() for addr in raw_addresses.split(",") if addr.strip())
+ADMIN_ADDRESSES = {
+ addr.strip().lower() for addr in raw_addresses.split(",") if addr.strip()
+}
+
class WalletSignIn(BaseModel):
address: str
signature: str
-@router.post("/wallet") # Changed from /wallet-signin
+
+@router.post("/wallet")
def wallet_auth(data: WalletSignIn) -> dict:
- """Sign in with wallet and return JWT"""
-
+ """
+ Verify wallet signature and return JWT.
+ Used for login on the frontend.
+ """
+
# Verify signature
message = "Sign in to Agonus"
+
try:
w3 = Web3()
encoded_message = encode_defunct(text=message)
- recovered_address = w3.eth.account.recover_message(encoded_message, signature=data.signature)
-
+
+ recovered_address = w3.eth.account.recover_message(
+ encoded_message, signature=data.signature
+ )
+
if recovered_address.lower() != data.address.lower():
- raise HTTPException(401, "Invalid signature")
+ raise HTTPException(status_code=401, detail="Invalid signature")
+
except Exception as e:
- raise HTTPException(401, f"Signature verification failed: {str(e)}")
-
- # Check role and issue JWT
+ raise HTTPException(status_code=401, detail=f"Signature verification failed: {str(e)}")
+
+ # Determine role
role = "admin" if data.address.lower() in ADMIN_ADDRESSES else "user"
-
+ print(f"[AUTH] Wallet: {data.address.lower()}, ADMIN_ADDRESSES: {ADMIN_ADDRESSES}, Role: {role}")
+
+ # Generate JWT
expire = datetime.utcnow() + timedelta(hours=24)
token = jwt.encode(
- {"sub": data.address.lower(), "role": role, "exp": expire},
+ {
+ "sub": data.address.lower(),
+ "role": role,
+ "exp": expire,
+ },
SECRET_KEY,
- algorithm=ALGORITHM
+ algorithm=ALGORITHM,
)
-
+
return {"access_token": token, "token_type": "bearer"}
diff --git a/backend/app/api/routers/bet.py b/backend/app/api/routers/bet.py
index 4302abe..5ce4b5c 100644
--- a/backend/app/api/routers/bet.py
+++ b/backend/app/api/routers/bet.py
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
+from datetime import timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from uuid import UUID
@@ -20,10 +21,11 @@ async def list_bets(session: AsyncSession = Depends(get_db)):
return bets
-# IMPORTANT: This route must come BEFORE /{bet_id} to avoid path conflicts
+# IMPORTANT: This route must come BEFORE /{bet_id}
@router.get("/my-bets", response_model=list[BetResponse])
async def get_user_bets(
- session: AsyncSession = Depends(get_db), user: dict = Depends(get_current_user)
+ session: AsyncSession = Depends(get_db),
+ user: dict = Depends(get_current_user)
):
"""GET route for current user's bets"""
statement = select(Bet).where(Bet.user_address == user["address"])
@@ -48,11 +50,14 @@ async def create_bet(
user: dict = Depends(get_current_user),
):
"""POST route for creating a new bet"""
- # Override user_address with authenticated user's address
- bet_dict = bet_data.model_dump()
- bet_dict["user_address"] = user[
- "address"
- ] # Ensure user can only bet for themselves
+ bet_dict = bet_data.model_dump(exclude_unset=True)
+ bet_dict["user_address"] = user["address"] # Force bet ownership
+
+ # Remove fields not in the database model
+ bet_dict.pop("tx_hash", None)
+ # Store naive UTC if DB column is TIMESTAMP WITHOUT TIME ZONE
+ if "placed_at" in bet_dict and bet_dict["placed_at"] is not None:
+ bet_dict["placed_at"] = bet_dict["placed_at"].replace(tzinfo=None)
bet = Bet(**bet_dict)
@@ -64,9 +69,11 @@ async def create_bet(
@router.patch("/{bet_id}/settle", response_model=BetResponse)
async def settle_bet(
- bet_id: UUID, payout: float, session: AsyncSession = Depends(get_db)
+ bet_id: UUID,
+ payout: float,
+ session: AsyncSession = Depends(get_db),
):
- """PATCH route for settling a bet (admin/system use)"""
+ """PATCH route for settling a bet"""
bet = await session.get(Bet, bet_id)
if not bet:
raise HTTPException(status_code=404, detail="Bet Not Found")
@@ -80,20 +87,20 @@ async def settle_bet(
session.add(bet)
await session.commit()
await session.refresh(bet)
-
return bet
@router.put("/{bet_id}", response_model=BetResponse)
async def update_bet(
- bet_id: UUID, bet_data: BetUpdate, session: AsyncSession = Depends(get_db)
+ bet_id: UUID,
+ bet_data: BetUpdate,
+ session: AsyncSession = Depends(get_db),
):
- """PUT route for updating a bet (typically only for settling)"""
+ """PUT route for updating a bet"""
db_bet = await session.get(Bet, bet_id)
if not db_bet:
raise HTTPException(status_code=404, detail="Bet Not Found")
- # Update only provided fields
update_data = bet_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
@@ -102,13 +109,12 @@ async def update_bet(
session.add(db_bet)
await session.commit()
await session.refresh(db_bet)
-
return db_bet
@router.delete("/{bet_id}")
async def delete_bet(bet_id: UUID, session: AsyncSession = Depends(get_db)):
- """DELETE route for deleting a bet (use with caution - breaks audit trail)"""
+ """DELETE route for deleting a bet"""
bet = await session.get(Bet, bet_id)
if not bet:
raise HTTPException(status_code=404, detail="Bet Not Found")
diff --git a/backend/app/api/routers/market_data.py b/backend/app/api/routers/market_data.py
index b13ea0e..16fc80f 100644
--- a/backend/app/api/routers/market_data.py
+++ b/backend/app/api/routers/market_data.py
@@ -2,7 +2,8 @@
import requests
from typing import Optional
-router = APIRouter()
+# Add correct prefix + tag so API documentation is clean
+router = APIRouter(prefix="/market-data", tags=["Market Data"])
@router.get("/prices")
@@ -15,13 +16,11 @@ async def get_market_prices(
):
"""GET route for live cryptocurrency prices from CoinGecko API"""
try:
- # Clean up token list
token_list = [t.strip() for t in tokens.split(",") if t.strip()]
if not token_list:
raise HTTPException(status_code=400, detail="No tokens specified")
- # Call CoinGecko API
url = "https://api.coingecko.com/api/v3/simple/price"
params = {
"ids": ",".join(token_list),
@@ -36,7 +35,6 @@ async def get_market_prices(
data = response.json()
- # Transform response to be more frontend-friendly
result = {}
for token_id in token_list:
if token_id in data:
@@ -73,7 +71,6 @@ async def get_single_price(
):
"""GET route for a single token's current price"""
try:
- # Call CoinGecko API
url = "https://api.coingecko.com/api/v3/simple/price"
params = {
"ids": token.lower(),
diff --git a/backend/app/api/routers/runtime_agents.py b/backend/app/api/routers/runtime_agents.py
new file mode 100644
index 0000000..1d8a73e
--- /dev/null
+++ b/backend/app/api/routers/runtime_agents.py
@@ -0,0 +1,169 @@
+from fastapi import APIRouter, HTTPException
+from datetime import datetime
+from typing import Dict, Any
+
+from app.tools.market_data import MarketDataTool
+from app.agents.executor import ACTIVE_AGENTS
+
+router = APIRouter(prefix="/runtime/agents", tags=["runtime-agents"])
+
+# Shared market tool instance for caching
+_market_tool = MarketDataTool()
+
+
+@router.get("/{agent_id}/portfolio")
+async def get_agent_portfolio(agent_id: int) -> Dict[str, Any]:
+ """Get the agent's current portfolio with live pricing."""
+ if agent_id not in ACTIVE_AGENTS:
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
+
+ agent = ACTIVE_AGENTS[agent_id]
+
+ holdings = agent.get_holdings()
+ cash = agent.get_available_cash()
+
+ all_prices = _market_tool.get_all_prices()
+
+ holdings_value = 0.0
+ holdings_enriched = {}
+
+ for token, qty in holdings.items():
+ price = all_prices.get(token, 0.0)
+ value = qty * price
+ holdings_value += value
+
+ holdings_enriched[token] = {
+ "quantity": qty,
+ "current_price": price,
+ "total_value": value,
+ }
+
+ total_value = cash + holdings_value
+
+ portfolio = agent.get_portfolio_status()
+
+ return {
+ "agent_id": agent_id,
+ "cash": cash,
+ "holdings": holdings_enriched,
+ "holdings_value": holdings_value,
+ "total_value": total_value,
+ "performance": {
+ "starting_value": portfolio.starting_val,
+ "realized_pnl": portfolio.realized_pnl,
+ "unrealized_pnl": portfolio.unrealized_pnl,
+ "total_pnl": portfolio.realized_pnl + portfolio.unrealized_pnl,
+ "roi": portfolio.roi,
+ "roi_percent": portfolio.roi * 100,
+ "num_trades": portfolio.num_trades,
+ "win_rate": portfolio.win_rate,
+ "num_winning_trades": portfolio.num_winning_trades,
+ "num_losing_trades": portfolio.num_losing_trades,
+ },
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+
+
+@router.get("/")
+async def list_agents() -> Dict[str, Any]:
+ """List all active agents with summaries."""
+
+ agents_list = []
+
+ all_prices = _market_tool.get_all_prices() if ACTIVE_AGENTS else {}
+
+ for agent_id, agent in ACTIVE_AGENTS.items():
+ holdings = agent.get_holdings()
+ cash = agent.get_available_cash()
+
+ holdings_value = sum(
+ qty * all_prices.get(token, 0.0) for token, qty in holdings.items()
+ )
+
+ total_value = cash + holdings_value
+
+ portfolio = agent.get_portfolio_status()
+
+ agents_list.append({
+ "agent_id": agent_id,
+ "name": f"Agent-{agent_id}",
+ "type": agent.__class__.__name__,
+ "personality": agent.personality,
+ "risk_score": agent.risk_score,
+ "total_value": total_value,
+ "cash": cash,
+ "holdings_value": holdings_value,
+ "roi": portfolio.roi,
+ "roi_percent": portfolio.roi * 100,
+ "num_trades": portfolio.num_trades,
+ "win_rate": portfolio.win_rate,
+ })
+
+ return {
+ "agents": agents_list,
+ "total_agents": len(agents_list),
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+
+
+@router.get("/market/prices")
+async def get_market_prices() -> Dict[str, Any]:
+ """Get current market prices for all supported tokens."""
+ prices = _market_tool.get_all_prices()
+ sentiment = _market_tool.get_market_sentiment()
+
+ enriched = {}
+
+ for token, price in prices.items():
+ try:
+ volume = _market_tool.get_volume(token)
+ enriched[token] = {
+ "symbol": token,
+ "price": price,
+ "volume_24h": volume,
+ }
+ except:
+ enriched[token] = {
+ "symbol": token,
+ "price": price,
+ }
+
+ return {
+ "prices": enriched,
+ "market_sentiment": sentiment,
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+
+
+@router.get("/{agent_id}/trades")
+async def get_agent_trades(agent_id: int, limit: int = 20) -> Dict[str, Any]:
+ """Get recent trades for an agent."""
+ if agent_id not in ACTIVE_AGENTS:
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
+
+ agent = ACTIVE_AGENTS[agent_id]
+ recent_trades = agent.get_short_term_memory(limit)
+
+ trades_list = [
+ {
+ "trade_id": t.trade_id,
+ "action": t.action,
+ "token": t.token,
+ "quantity": t.qty,
+ "price": t.price,
+ "total_value": t.qty * t.price,
+ "confidence": t.confidence,
+ "realized_pnl": t.realized_pnl,
+ "roi": t.roi,
+ "summary": t.summary,
+ "timestamp": t.timestamp.isoformat(),
+ }
+ for t in recent_trades
+ ]
+
+ return {
+ "agent_id": agent_id,
+ "trades": trades_list,
+ "count": len(trades_list),
+ "timestamp": datetime.utcnow().isoformat(),
+ }
diff --git a/backend/app/api/routers/tournament.py b/backend/app/api/routers/tournament.py
index 008bf38..89ece44 100644
--- a/backend/app/api/routers/tournament.py
+++ b/backend/app/api/routers/tournament.py
@@ -1,36 +1,45 @@
+import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from uuid import UUID
+from datetime import datetime, timezone
from ...agents.data_classes import Portfolio
-import logging
-import asyncio
-from typing import List, Dict, Any, Optional
-from uuid import UUID
-from datetime import datetime, timezone, timedelta
-
-from celery import Task, group, chain
-from sqlalchemy import select, update
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from ...celery_config import celery_app
from ...db.database import AsyncSessionLocal, get_db
from ...db.models import Tournament, Agent, AgentState, StatusEnum
-from ...agents.executor import TradingAgent
from ...agents.tools import DatabaseTool
from ...schemas.tournament import (
TournamentCreate,
TournamentUpdate,
TournamentResponse,
+ TournamentContractLink,
+ TournamentOnchainCreate,
+ TournamentOnchainSettle,
)
from ...schemas.agent_state import AgentStateResponse
from ..deps import require_admin
from ...agents.scheduler import (
run_agent_decision,
- initialize_tournament_agents,
+ _initialize_tournament_agents_async,
)
+from ...onchain.agonus_betting import get_agonus_client
+
+logger = logging.getLogger(__name__)
+
+
+def _extract_revert_reason(exc: Exception) -> str | None:
+ """Extract a human-readable revert reason from a web3 exception."""
+ msg = str(exc)
+ if "execution reverted" in msg.lower():
+ return msg
+ if hasattr(exc, "args") and exc.args:
+ for arg in exc.args:
+ if isinstance(arg, dict) and "message" in arg:
+ return arg["message"]
+ return None
+
router = APIRouter()
@@ -57,146 +66,32 @@ async def get_tournament(tournament_id: UUID, session: AsyncSession = Depends(ge
async def get_tournament_leaderboard(
tournament_id: UUID, session: AsyncSession = Depends(get_db)
):
- """GET route for tournament leaderboard - agents ranked by portfolio value"""
- # Verify tournament exists
+ """GET leaderboard ordered by portfolio value"""
tournament = await session.get(Tournament, tournament_id)
if not tournament:
raise HTTPException(status_code=404, detail="Tournament Not Found")
- # Get all agent states for this tournament, ordered by portfolio value
statement = (
select(AgentState)
.where(AgentState.tournament_id == tournament_id)
.order_by(AgentState.portfolio_value_usd.desc())
)
result = await session.execute(statement)
- agent_states = result.scalars().all()
-
- return agent_states
+ return result.scalars().all()
@router.get("/{tournament_id}/agents", response_model=list[AgentStateResponse])
async def get_tournament_agents(
tournament_id: UUID, session: AsyncSession = Depends(get_db)
):
- """GET route for all agents in a tournament with their current state"""
- # Verify tournament exists
+ """GET agents participating in tournament"""
tournament = await session.get(Tournament, tournament_id)
if not tournament:
raise HTTPException(status_code=404, detail="Tournament Not Found")
- # Get all agent states for this tournament
statement = select(AgentState).where(AgentState.tournament_id == tournament_id)
result = await session.execute(statement)
- agent_states = result.scalars().all()
-
- return agent_states
-
-
-# @router.post("/", response_model=TournamentResponse, status_code=201)
-# async def create_tournament(
-# tournament_data: TournamentCreate,
-# session: AsyncSession = Depends(get_db),
-# admin: dict = Depends(require_admin),
-# ):
-# pass
-
-# def initialize_tournament_agents(
-# tournament_uuid: str,
-# agent_uuids: List[str]
-# ) -> Dict[str, Any]:
-# """
-# Initialize agent states for a new tournament.
-
-# Args:
-# tournament_uuid: Tournament UUID (as string)
-# agent_uuids: List of agent UUIDs (as strings)
-
-# Returns:
-# Dict with initialization results
-# """
-# # logger.info(f"Initializing agents for tournament: {tournament_uuid}")
-# try:
-# return asyncio.run(
-# _initialize_tournament_agents_async(
-# UUID(tournament_uuid),
-# [UUID(uuid) for uuid in agent_uuids]
-# )
-# )
-# except Exception as e:
-# # logger.error(f"Failed to initialize tournament agents: {e}")
-# raise
-
-
-# async def _initialize_tournament_agents_async(
-# tournament_uuid: UUID,
-# agent_uuids: List[UUID]
-# ) -> Dict[str, Any]:
-# """
-# Async implementation of agent initialization.
-# """
-# async with AsyncSessionLocal() as session:
-# db_tool = DatabaseTool(session)
-# initialized = []
-
-# for agent_uuid in agent_uuids:
-# # Load agent
-# stmt = select(Agent).where(Agent.id == agent_uuid)
-# result = await session.execute(stmt)
-# agent = result.scalar_one_or_none()
-
-# if not agent:
-# # logger.warning(f"Agent not found: {agent_uuid}")
-# continue
-
-# # Create initial portfolio
-# from ...agents.data_classes import Portfolio
-
-# portfolio = Portfolio(
-# agent_id=agent.name,
-# cash=500.0,
-# holdings={},
-# starting_val=500.0,
-# total_value=500.0
-# )
-
-# # Save initial state
-# await db_tool.save_agent_state(
-# agent_uuid=agent_uuid,
-# tournament_uuid=tournament_uuid,
-# portfolio=portfolio,
-# rank=0,
-# last_decision="Tournament initialized"
-# )
-
-# initialized.append(str(agent_uuid))
-# # logger.info(f"Initialized agent: {agent.name}")
-
-# return {
-# "tournament_id": str(tournament_uuid),
-# "agents_initialized": len(initialized),
-# "agent_ids": initialized,
-# "timestamp": datetime.now(timezone.utc).isoformat()
-# }
-
-# """POST route to create a new tournament"""
-# # Create tournament from schema, excluding agent_ids (not a Tournament model field)
-# tournament_dict = tournament_data.model_dump(exclude={"agent_ids"})
-# tournament = Tournament(**tournament_dict)
-
-# session.add(tournament)
-# await session.commit()
-# await session.refresh(tournament)
-
-# return tournament
-
-
-# @router.post("/{tournament_id}/start")
-# async def start_tournament(tournament_id: str):
-# initialize_tournament_agents.delay(
-# tournament_uuid=tournament_id, agent_uuids=["uuid-1", "uuid-2"]
-# )
-# return {"message": "Tournament initialization started"}
+ return result.scalars().all()
async def initialize_agents_for_tournament(
@@ -239,7 +134,7 @@ async def create_tournament(
data: TournamentCreate,
session: AsyncSession = Depends(get_db),
):
- # 1️⃣ Create the tournament row
+ # Create the tournament row
tournament = Tournament(
name=data.name,
start_date=data.start_date,
@@ -251,27 +146,236 @@ async def create_tournament(
session.add(tournament)
await session.flush() # ensures tournament.id exists without commit
- # 2️⃣ Initialize agent states
+ # Initialize agent states
await initialize_agents_for_tournament(
session=session,
tournament_id=tournament.id,
agent_ids=data.agent_ids,
)
- # 3️⃣ Commit the whole transaction atomically
+ # Commit the whole transaction atomically
await session.commit()
await session.refresh(tournament)
return tournament
+# ── On-chain smart contract endpoints (admin-gated) ──
+
+
+@router.post(
+ "/{tournament_id}/onchain/create",
+ response_model=TournamentContractLink,
+ status_code=201,
+)
+async def create_onchain_tournament(
+ tournament_id: UUID,
+ payload: TournamentOnchainCreate,
+ session: AsyncSession = Depends(get_db),
+ admin: dict = Depends(require_admin),
+):
+ """Create tournament on-chain and link it to DB record."""
+ tournament = await session.get(Tournament, tournament_id)
+ if not tournament:
+ raise HTTPException(status_code=404, detail="Tournament Not Found")
+
+ if tournament.contract_tournament_id is not None:
+ raise HTTPException(status_code=400, detail="Tournament already linked on-chain")
+
+ if len(payload.agent_ids) < 2:
+ raise HTTPException(status_code=400, detail="At least 2 agents required")
+
+ try:
+ client = get_agonus_client()
+ result = client.create_tournament(agent_count=len(payload.agent_ids))
+ except (ValueError, ConnectionError) as e:
+ raise HTTPException(status_code=400, detail=f"Configuration error: {e}")
+ except Exception as e:
+ logger.exception("On-chain create failed")
+ detail = _extract_revert_reason(e) or str(e)
+ raise HTTPException(status_code=400, detail=f"On-chain create failed: {detail}")
+
+ # Sort agent IDs deterministically so the mapping is always the same
+ sorted_ids = sorted(str(aid) for aid in payload.agent_ids)
+ mapping = {agent_id: idx + 1 for idx, agent_id in enumerate(sorted_ids)}
+ tournament.contract_tournament_id = result.contract_tournament_id
+ tournament.agent_contract_mapping = mapping
+
+ session.add(tournament)
+ await session.commit()
+ await session.refresh(tournament)
+
+ return TournamentContractLink(
+ contract_tournament_id=result.contract_tournament_id or 0,
+ tx_hash=result.tx_hash,
+ )
+
+
+@router.post("/{tournament_id}/start")
+async def start_tournament(
+ tournament_id: UUID,
+ session: AsyncSession = Depends(get_db),
+ admin: dict = Depends(require_admin),
+):
+ """Start tournament -- initializes agent states via background task."""
+ tournament = await session.get(Tournament, tournament_id)
+ if not tournament:
+ raise HTTPException(status_code=404, detail="Tournament Not Found")
+ if not tournament.agent_contract_mapping:
+ raise HTTPException(status_code=400, detail="No agents mapped. Link on-chain first.")
+
+ agent_uuids = list(tournament.agent_contract_mapping.keys())
+ await _initialize_tournament_agents_async(
+ tournament_id, [UUID(u) for u in agent_uuids]
+ )
+
+ # Update agent stats: increment total_tournaments
+ for uid in agent_uuids:
+ agent = await session.get(Agent, UUID(uid))
+ if agent:
+ stats = dict(agent.stats or {})
+ stats["total_tournaments"] = stats.get("total_tournaments", 0) + 1
+ agent.stats = stats
+ session.add(agent)
+
+ tournament.status = "live"
+ session.add(tournament)
+ await session.commit()
+
+ return {"message": "Tournament started", "agents_count": len(agent_uuids)}
+
+
+@router.post("/{tournament_id}/onchain/close")
+async def close_onchain_betting(
+ tournament_id: UUID,
+ session: AsyncSession = Depends(get_db),
+ admin: dict = Depends(require_admin),
+):
+ """Close betting on-chain."""
+ tournament = await session.get(Tournament, tournament_id)
+ if not tournament:
+ raise HTTPException(status_code=404, detail="Tournament Not Found")
+ if tournament.contract_tournament_id is None:
+ raise HTTPException(status_code=400, detail="Tournament not linked on-chain")
+ if tournament.status == "completed":
+ raise HTTPException(status_code=400, detail="Tournament already completed or cancelled")
+ if tournament.betting_closed:
+ raise HTTPException(status_code=400, detail="Betting already closed")
+ if tournament.winner_agent_id is not None:
+ raise HTTPException(status_code=400, detail="Tournament already settled")
+
+ try:
+ client = get_agonus_client()
+ result = client.close_betting(tournament.contract_tournament_id)
+ except (ValueError, ConnectionError) as e:
+ raise HTTPException(status_code=400, detail=f"Configuration error: {e}")
+ except Exception as e:
+ logger.exception("On-chain close failed")
+ detail = _extract_revert_reason(e) or str(e)
+ raise HTTPException(status_code=400, detail=f"On-chain close failed: {detail}")
+
+ tournament.betting_closed = True
+ session.add(tournament)
+ await session.commit()
+
+ return {"tx_hash": result.tx_hash}
+
+
+@router.post("/{tournament_id}/onchain/settle")
+async def settle_onchain_tournament(
+ tournament_id: UUID,
+ payload: TournamentOnchainSettle,
+ session: AsyncSession = Depends(get_db),
+ admin: dict = Depends(require_admin),
+):
+ """Settle tournament on-chain using winner agent mapping."""
+ tournament = await session.get(Tournament, tournament_id)
+ if not tournament:
+ raise HTTPException(status_code=404, detail="Tournament Not Found")
+ if tournament.contract_tournament_id is None:
+ raise HTTPException(status_code=400, detail="Tournament not linked on-chain")
+ if not tournament.betting_closed:
+ raise HTTPException(status_code=400, detail="Must close betting before settling")
+ if tournament.winner_agent_id is not None:
+ raise HTTPException(status_code=400, detail="Tournament already settled")
+
+ contract_agent_id = tournament.agent_contract_mapping.get(str(payload.winner_agent_id))
+ if not contract_agent_id:
+ raise HTTPException(status_code=400, detail="Winner agent not mapped on-chain")
+
+ try:
+ client = get_agonus_client()
+ result = client.settle_tournament(
+ tournament.contract_tournament_id, contract_agent_id
+ )
+ except (ValueError, ConnectionError) as e:
+ raise HTTPException(status_code=400, detail=f"Configuration error: {e}")
+ except Exception as e:
+ logger.exception("On-chain settle failed")
+ detail = _extract_revert_reason(e) or str(e)
+ raise HTTPException(status_code=400, detail=f"On-chain settle failed: {detail}")
+
+ tournament.winner_agent_id = payload.winner_agent_id
+ tournament.status = "completed"
+ session.add(tournament)
+
+ # Update agent stats: increment wins for winner, recalculate win_rate for all
+ for uid in (tournament.agent_contract_mapping or {}).keys():
+ agent = await session.get(Agent, UUID(uid))
+ if not agent:
+ continue
+ stats = dict(agent.stats or {})
+ if UUID(uid) == payload.winner_agent_id:
+ stats["wins"] = stats.get("wins", 0) + 1
+ total = stats.get("total_tournaments", 0)
+ wins = stats.get("wins", 0)
+ stats["win_rate"] = wins / total if total > 0 else 0
+ agent.stats = stats
+ session.add(agent)
+
+ await session.commit()
+
+ return {"tx_hash": result.tx_hash}
+
+
+@router.post("/{tournament_id}/onchain/cancel")
+async def cancel_onchain_tournament(
+ tournament_id: UUID,
+ session: AsyncSession = Depends(get_db),
+ admin: dict = Depends(require_admin),
+):
+ """Cancel tournament on-chain."""
+ tournament = await session.get(Tournament, tournament_id)
+ if not tournament:
+ raise HTTPException(status_code=404, detail="Tournament Not Found")
+ if tournament.contract_tournament_id is None:
+ raise HTTPException(status_code=400, detail="Tournament not linked on-chain")
+ if tournament.winner_agent_id is not None:
+ raise HTTPException(status_code=400, detail="Tournament already settled, cannot cancel")
+
+ try:
+ client = get_agonus_client()
+ result = client.cancel_tournament(tournament.contract_tournament_id)
+ except (ValueError, ConnectionError) as e:
+ raise HTTPException(status_code=400, detail=f"Configuration error: {e}")
+ except Exception as e:
+ logger.exception("On-chain cancel failed")
+ detail = _extract_revert_reason(e) or str(e)
+ raise HTTPException(status_code=400, detail=f"On-chain cancel failed: {detail}")
+
+ tournament.status = "completed"
+ tournament.betting_closed = True
+ session.add(tournament)
+ await session.commit()
+
+ return {"tx_hash": result.tx_hash}
+
+
@router.post("/agents/{agent_id}/force-run")
async def force_agent_run(agent_id: str, tournament_id: str):
- # 2. Force a single agent to think NOW
task = run_agent_decision.delay(
agent_uuid=agent_id, tournament_uuid=tournament_id, recover_from_crash=True
)
-
return {"task_id": task.id, "status": "Queued"}
@@ -282,12 +386,11 @@ async def update_tournament(
session: AsyncSession = Depends(get_db),
admin: dict = Depends(require_admin),
):
- """PUT route for updating a tournament"""
+ """PUT update tournament"""
db_tournament = await session.get(Tournament, tournament_id)
if not db_tournament:
raise HTTPException(status_code=404, detail="Tournament Not Found")
- # Update only provided fields
update_data = tournament_data.model_dump(exclude_unset=True)
for key, value in update_data.items():
@@ -306,7 +409,7 @@ async def delete_tournament(
session: AsyncSession = Depends(get_db),
admin: dict = Depends(require_admin),
):
- """DELETE route for deleting a tournament"""
+ """DELETE tournament"""
tournament = await session.get(Tournament, tournament_id)
if not tournament:
raise HTTPException(status_code=404, detail="Tournament Not Found")
diff --git a/backend/app/api/routers/trade.py b/backend/app/api/routers/trade.py
index 5269931..9ae4aa6 100644
--- a/backend/app/api/routers/trade.py
+++ b/backend/app/api/routers/trade.py
@@ -68,11 +68,9 @@ async def create_trade(
trade_data: TradeCreate, session: AsyncSession = Depends(get_db)
):
"""POST route for creating a new trade"""
- # Create Trade model from schema
trade = Trade(**trade_data.model_dump())
session.add(trade)
await session.commit()
await session.refresh(trade)
return trade
-
diff --git a/backend/app/db/database.py b/backend/app/db/database.py
index d4901e9..dd33eb2 100644
--- a/backend/app/db/database.py
+++ b/backend/app/db/database.py
@@ -6,9 +6,7 @@
# Load environment variables
load_dotenv()
-DATABASE_URL = os.getenv("DATABASE_URL")
-if not DATABASE_URL:
- raise ValueError("DATABASE_URL environment variable is not set")
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./dev.db")
# --- SCALABILITY TUNING ---
# These defaults are safe, but you should override them in docker-compose:
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index bae1a38..2f15fb0 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -9,6 +9,7 @@
String,
Numeric,
Integer,
+ Boolean,
Enum as SQLEnum,
Index,
DateTime,
@@ -73,6 +74,7 @@ class Tournament(Base):
agent_contract_mapping: Mapped[dict] = mapped_column(
JSON, default=dict
) # {"uuid": 1, "uuid2": 2}
+ betting_closed: Mapped[bool] = mapped_column(Boolean, default=False)
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String, index=True)
status: Mapped[StatusEnum] = mapped_column(
@@ -161,20 +163,21 @@ class Bet(Base):
user_address: Mapped[str] = mapped_column(String, index=True)
agent_id: Mapped[UUID] = mapped_column(ForeignKey("agent.id"), index=True)
tournament_id: Mapped[UUID] = mapped_column(ForeignKey("tournament.id"), index=True)
- amount: Mapped[Decimal] = mapped_column(Numeric(precision=20, scale=2))
- odds: Mapped[Decimal] = mapped_column(Numeric(precision=10, scale=2))
+ amount: Mapped[Decimal] = mapped_column(Numeric(precision=20, scale=8))
+ odds: Mapped[Decimal] = mapped_column(Numeric(precision=10, scale=4))
placed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
settled: Mapped[bool] = mapped_column(default=False)
payout: Mapped[Optional[Decimal]] = mapped_column(
- Numeric(precision=20, scale=2), default=None
+ Numeric(precision=20, scale=8), default=None
)
tournament: Mapped["Tournament"] = relationship(back_populates="bets")
agent: Mapped["Agent"] = relationship(back_populates="bets")
+
class PlanItem(Base):
__tablename__ = "plan_item"
diff --git a/backend/app/onchain/abi/AgonusBetting.json b/backend/app/onchain/abi/AgonusBetting.json
new file mode 100644
index 0000000..13815af
--- /dev/null
+++ b/backend/app/onchain/abi/AgonusBetting.json
@@ -0,0 +1,575 @@
+[
+ {
+ "inputs": [],
+ "stateMutability": "nonpayable",
+ "type": "constructor"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "OwnableInvalidOwner",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ }
+ ],
+ "name": "OwnableUnauthorizedAccount",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "ReentrancyGuardReentrantCall",
+ "type": "error"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "user",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "agentId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "BetPlaced",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "BettingClosed",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "previousOwner",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "newOwner",
+ "type": "address"
+ }
+ ],
+ "name": "OwnershipTransferred",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "TournamentCancelled",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "agentCount",
+ "type": "uint256"
+ }
+ ],
+ "name": "TournamentCreated",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "winningAgentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "TournamentSettled",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "user",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "WinningsClaimed",
+ "type": "event"
+ },
+ {
+ "inputs": [],
+ "name": "BP_DIVISOR",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "MIN_BET",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "PLATFORM_FEE_BP",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "name": "agentPools",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "address",
+ "name": "user",
+ "type": "address"
+ }
+ ],
+ "name": "calculatePayout",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "cancelTournament",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "claimWinnings",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "closeBetting",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "agentCount",
+ "type": "uint256"
+ }
+ ],
+ "name": "createTournament",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "agentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "getAgentOdds",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "address",
+ "name": "user",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "agentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "getUserBetOnAgent",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ },
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "name": "hasClaimed",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "nextTournamentId",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "owner",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "agentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "placeBet",
+ "outputs": [],
+ "stateMutability": "payable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "platformWallet",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "renounceOwnership",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "newWallet",
+ "type": "address"
+ }
+ ],
+ "name": "setPlatformWallet",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tournamentId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "winningAgentId",
+ "type": "uint256"
+ }
+ ],
+ "name": "settleTournament",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "name": "tournaments",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "isActive",
+ "type": "bool"
+ },
+ {
+ "internalType": "bool",
+ "name": "isSettled",
+ "type": "bool"
+ },
+ {
+ "internalType": "uint256",
+ "name": "totalPool",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "winningAgentId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "uint256",
+ "name": "agentCount",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "newOwner",
+ "type": "address"
+ }
+ ],
+ "name": "transferOwnership",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ },
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "name": "userBets",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "stateMutability": "payable",
+ "type": "receive"
+ }
+]
\ No newline at end of file
diff --git a/backend/app/onchain/agonus_betting.py b/backend/app/onchain/agonus_betting.py
new file mode 100644
index 0000000..824af84
--- /dev/null
+++ b/backend/app/onchain/agonus_betting.py
@@ -0,0 +1,110 @@
+import json
+import os
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from web3 import Web3
+from web3.contract import Contract
+from eth_account import Account
+
+
+@dataclass
+class TxResult:
+ tx_hash: str
+ contract_tournament_id: Optional[int] = None
+
+
+class AgonusBettingClient:
+ def __init__(
+ self,
+ rpc_url: str,
+ private_key: str,
+ contract_address: str,
+ abi_path: str,
+ chain_id: Optional[int] = None,
+ ) -> None:
+ if not rpc_url:
+ raise ValueError("RPC URL is required")
+ if not private_key:
+ raise ValueError("Private key is required")
+ if not contract_address:
+ raise ValueError("Contract address is required")
+
+ self.w3 = Web3(Web3.HTTPProvider(rpc_url))
+ if not self.w3.is_connected():
+ raise ConnectionError("Failed to connect to RPC")
+
+ self.account = Account.from_key(private_key)
+ self.chain_id = chain_id or int(os.getenv("CHAIN_ID", "0")) or self.w3.eth.chain_id
+ self.contract = self._load_contract(contract_address, abi_path)
+
+ def _load_contract(self, contract_address: str, abi_path: str) -> Contract:
+ with open(abi_path, "r", encoding="utf-8") as f:
+ abi = json.load(f)
+ return self.w3.eth.contract(
+ address=Web3.to_checksum_address(contract_address),
+ abi=abi,
+ )
+
+ def _build_tx(self, fn) -> dict[str, Any]:
+ nonce = self.w3.eth.get_transaction_count(self.account.address)
+ tx = fn.build_transaction(
+ {
+ "from": self.account.address,
+ "nonce": nonce,
+ "chainId": self.chain_id,
+ }
+ )
+
+ # EIP-1559 style fees
+ max_fee = self.w3.eth.gas_price
+ max_priority = self.w3.eth.max_priority_fee
+ tx["maxFeePerGas"] = max_fee
+ tx["maxPriorityFeePerGas"] = max_priority
+ tx["gas"] = self.w3.eth.estimate_gas(tx)
+ return tx
+
+ def _send_tx(self, fn) -> str:
+ tx = self._build_tx(fn)
+ signed = self.account.sign_transaction(tx)
+ tx_hash = self.w3.eth.send_raw_transaction(signed.raw_transaction)
+ return tx_hash.hex()
+
+ def _wait_receipt(self, tx_hash: str) -> Any:
+ return self.w3.eth.wait_for_transaction_receipt(tx_hash)
+
+ def create_tournament(self, agent_count: int) -> TxResult:
+ tx_hash = self._send_tx(self.contract.functions.createTournament(agent_count))
+ receipt = self._wait_receipt(tx_hash)
+ created = self.contract.events.TournamentCreated().process_receipt(receipt)
+ tournament_id = created[0]["args"]["tournamentId"] if created else None
+ return TxResult(tx_hash=tx_hash, contract_tournament_id=tournament_id)
+
+ def close_betting(self, tournament_id: int) -> TxResult:
+ tx_hash = self._send_tx(self.contract.functions.closeBetting(tournament_id))
+ return TxResult(tx_hash=tx_hash)
+
+ def settle_tournament(self, tournament_id: int, winning_agent_id: int) -> TxResult:
+ tx_hash = self._send_tx(
+ self.contract.functions.settleTournament(tournament_id, winning_agent_id)
+ )
+ return TxResult(tx_hash=tx_hash)
+
+ def cancel_tournament(self, tournament_id: int) -> TxResult:
+ tx_hash = self._send_tx(self.contract.functions.cancelTournament(tournament_id))
+ return TxResult(tx_hash=tx_hash)
+
+
+def get_agonus_client() -> AgonusBettingClient:
+ rpc_url = os.getenv("SEPOLIA_RPC_URL") or os.getenv("RPC_URL", "")
+ private_key = os.getenv("SEPOLIA_PRIVATE_KEY") or os.getenv("PRIVATE_KEY", "")
+ contract_address = os.getenv("CONTRACT_ADDRESS", "")
+ abi_path = os.path.join(
+ os.path.dirname(__file__), "abi", "AgonusBetting.json"
+ )
+ return AgonusBettingClient(
+ rpc_url=rpc_url,
+ private_key=private_key,
+ contract_address=contract_address,
+ abi_path=abi_path,
+ )
diff --git a/backend/app/schemas/bet.py b/backend/app/schemas/bet.py
index 0f374ff..2edc92a 100644
--- a/backend/app/schemas/bet.py
+++ b/backend/app/schemas/bet.py
@@ -13,7 +13,13 @@ class BetBase(BaseModel):
odds: Decimal
-class BetCreate(BetBase):
+class BetCreate(BaseModel):
+ """Schema for creating a bet - user_address comes from JWT token"""
+ agent_id: UUID
+ tournament_id: UUID
+ amount: Decimal
+ odds: Decimal = Decimal("1.0")
+ tx_hash: Optional[str] = None
placed_at: Optional[datetime] = None
settled: Optional[bool] = False
payout: Optional[Decimal] = None
diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py
index 60b448a..d3cb5bc 100644
--- a/backend/app/schemas/tournament.py
+++ b/backend/app/schemas/tournament.py
@@ -34,6 +34,16 @@ class TournamentContractLink(BaseModel):
tx_hash: Optional[str] = None # For verification/tracking
+# NEW: Schema for on-chain tournament creation
+class TournamentOnchainCreate(BaseModel):
+ agent_ids: list[UUID]
+
+
+# NEW: Schema for on-chain tournament settlement
+class TournamentOnchainSettle(BaseModel):
+ winner_agent_id: UUID
+
+
# Schema for responses (GET)
class TournamentResponse(TournamentBase):
id: UUID
@@ -41,8 +51,9 @@ class TournamentResponse(TournamentBase):
created_at: datetime
winner_agent_id: Optional[UUID] = None
- # NEW: Contract integration fields
+ # Contract integration fields
contract_tournament_id: Optional[int] = None
agent_contract_mapping: dict[str, int] = {} # {"agent-uuid": 1, "agent-uuid2": 2}
+ betting_closed: bool = False
model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/scripts/add_contract_columns.py b/backend/app/scripts/add_contract_columns.py
new file mode 100644
index 0000000..5abb609
--- /dev/null
+++ b/backend/app/scripts/add_contract_columns.py
@@ -0,0 +1,113 @@
+"""
+Safely add smart contract columns to the tournament table.
+
+Adds 3 columns without touching existing data:
+ - contract_tournament_id (INTEGER, nullable) — links to on-chain tournament
+ - agent_contract_mapping (JSONB, default '{}') — maps agent UUIDs to contract IDs
+ - betting_closed (BOOLEAN, default false) — whether betting is closed
+
+Uses IF NOT EXISTS so it's safe to run multiple times.
+After adding columns, populates mock values for existing tournaments.
+"""
+import asyncio
+import os
+
+from dotenv import load_dotenv
+import asyncpg
+
+load_dotenv()
+
+DATABASE_URL = os.getenv("DATABASE_URL", "")
+PG_URL = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
+
+
+async def migrate():
+ if not DATABASE_URL:
+ print("Error: DATABASE_URL not found in environment.")
+ return
+
+ conn = await asyncpg.connect(PG_URL, ssl="require")
+
+ try:
+ # Step 1: Add columns (safe — IF NOT EXISTS)
+ print("Adding contract_tournament_id column...")
+ await conn.execute("""
+ ALTER TABLE tournament
+ ADD COLUMN IF NOT EXISTS contract_tournament_id INTEGER DEFAULT NULL
+ """)
+
+ print("Adding agent_contract_mapping column...")
+ await conn.execute("""
+ ALTER TABLE tournament
+ ADD COLUMN IF NOT EXISTS agent_contract_mapping JSONB DEFAULT '{}'::jsonb
+ """)
+
+ print("Adding betting_closed column...")
+ await conn.execute("""
+ ALTER TABLE tournament
+ ADD COLUMN IF NOT EXISTS betting_closed BOOLEAN DEFAULT false
+ """)
+
+ # Step 2: Add index on contract_tournament_id
+ print("Adding index on contract_tournament_id...")
+ await conn.execute("""
+ CREATE INDEX IF NOT EXISTS ix_tournament_contract_id
+ ON tournament(contract_tournament_id)
+ """)
+
+ # Step 3: Populate mock values for existing tournaments
+ print("\nPopulating mock values for existing tournaments...")
+ tournaments = await conn.fetch(
+ "SELECT id, name FROM tournament WHERE contract_tournament_id IS NULL"
+ )
+
+ for i, t in enumerate(tournaments):
+ mock_contract_id = i + 1
+
+ # Get agents in this tournament to build a mock mapping
+ agents = await conn.fetch(
+ "SELECT agent_id FROM agent_state WHERE tournament_id = $1", t["id"]
+ )
+
+ # If no agent_states, try getting all agents as fallback
+ if not agents:
+ agents = await conn.fetch("SELECT id as agent_id FROM agent")
+
+ mock_mapping = {}
+ for j, agent in enumerate(agents):
+ mock_mapping[str(agent["agent_id"])] = j + 1
+
+ await conn.execute(
+ """
+ UPDATE tournament
+ SET contract_tournament_id = $1,
+ agent_contract_mapping = $2::jsonb,
+ betting_closed = $3
+ WHERE id = $4
+ """,
+ mock_contract_id,
+ str(mock_mapping).replace("'", '"'),
+ False,
+ t["id"],
+ )
+ print(f" {t['name']}: contract_id={mock_contract_id}, {len(mock_mapping)} agents mapped")
+
+ print("\nMigration complete! Verifying...")
+
+ # Step 4: Verify
+ rows = await conn.fetch(
+ "SELECT name, contract_tournament_id, agent_contract_mapping, betting_closed FROM tournament"
+ )
+ for row in rows:
+ print(f" {row['name']}: contract_id={row['contract_tournament_id']}, "
+ f"mapping={row['agent_contract_mapping']}, betting_closed={row['betting_closed']}")
+
+ except Exception as e:
+ print(f"Migration failed: {e}")
+ raise
+ finally:
+ await conn.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(migrate())
diff --git a/backend/app/scripts/reset_db.py b/backend/app/scripts/reset_db.py
index 2204af8..92dce7d 100644
--- a/backend/app/scripts/reset_db.py
+++ b/backend/app/scripts/reset_db.py
@@ -2,11 +2,12 @@
import os # <--- 1. Was missing
from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import create_async_engine
+from sqlalchemy import text
# 3. Correct Import: 'Bet', not 'Bets'
from ..db.models import Base, Tournament, Agent, AgentState, Trade, Bet, PlanItem, AgentResearchArtifact
-load_dotenv()
+load_dotenv(dotenv_path=Path(__file__).resolve().parents[2] / ".env")
DATABASE_URL = os.getenv("DATABASE_URL")
DB_DISABLE_SSL = os.getenv("DB_DISABLE_SSL", "false").lower() == "true"
@@ -14,7 +15,7 @@
async def reset_database():
if not DATABASE_URL:
- print("❌ Error: DATABASE_URL not found in environment.")
+ print("Error: DATABASE_URL not found in environment.")
return
print("⚡ Connecting to database...")
@@ -28,13 +29,16 @@ async def reset_database():
)
async with engine.begin() as conn:
- print("🔥 Dropping all tables...")
- await conn.run_sync(Base.metadata.drop_all)
+ print("Dropping all tables (CASCADE)...")
+ await conn.execute(text("DROP SCHEMA public CASCADE"))
+ await conn.execute(text("CREATE SCHEMA public"))
+ await conn.execute(text("GRANT ALL ON SCHEMA public TO neondb_owner"))
+ await conn.execute(text("GRANT ALL ON SCHEMA public TO public"))
- print("🏗️ Creating new tables...")
+ print("Creating new tables...")
await conn.run_sync(Base.metadata.create_all)
- print("✅ Database reset successfully!")
+ print("Database reset successfully!")
await engine.dispose()
@@ -42,4 +46,4 @@ async def reset_database():
try:
asyncio.run(reset_database())
except Exception as e:
- print(f"❌ Error: {e}")
+ print(f"Error: {e}")
diff --git a/tailwind.config.js b/backend/app/tools/__init__.py
similarity index 100%
rename from tailwind.config.js
rename to backend/app/tools/__init__.py
diff --git a/backend/app/tools/market_data.py b/backend/app/tools/market_data.py
new file mode 100644
index 0000000..f780b0b
--- /dev/null
+++ b/backend/app/tools/market_data.py
@@ -0,0 +1,72 @@
+# backend/app/tools/market_data.py
+
+import logging
+from datetime import datetime
+from typing import Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+class MarketDataTool:
+ def __init__(self, api_base: str = None, api_key: str = None):
+ self.api_base = api_base or "https://api.coingecko.com/api/v3"
+ self.api_key = api_key
+
+ # REQUIRED for our runtime agents
+ self.supported_tokens = {
+ "BTC": "bitcoin",
+ "ETH": "ethereum",
+ "WETH": "weth",
+ "CBBTC": "bitcoin",
+ }
+
+ # cache
+ self._price_cache: Dict[str, float] = {}
+ self._cache_timestamp: Optional[datetime] = None
+ self._cache_ttl_seconds: int = 5
+
+ def _is_cache_valid(self) -> bool:
+ if not self._cache_timestamp:
+ return False
+ age = (datetime.utcnow() - self._cache_timestamp).total_seconds()
+ return age < self._cache_ttl_seconds
+
+ # -------------------------------------------------------------------
+ # MOCK PRICE FUNCTION for frontend development
+ # -------------------------------------------------------------------
+ def get_price(self, token: str):
+ mock = {
+ "btc": {"usd": 42000},
+ "eth": {"usd": 2300},
+ "weth": {"usd": 2300},
+ "cbbtc": {"usd": 42000},
+ }
+ return mock.get(token.lower(), {"usd": 0})
+
+ def get_all_prices(self) -> Dict[str, float]:
+ if self._is_cache_valid():
+ return self._price_cache.copy()
+
+ prices = {}
+ for token in self.supported_tokens.keys():
+ try:
+ price_data = self.get_price(token)
+ prices[token] = price_data.get("usd", 0)
+ except Exception as e:
+ logger.error(f"Price fetch fail for {token}: {e}")
+ prices[token] = self._price_cache.get(token, 0)
+
+ self._price_cache = prices
+ self._cache_timestamp = datetime.utcnow()
+ return prices.copy()
+
+ # -------------------------------------------------------------------
+ # MOCK SENTIMENT + VOLUME
+ # -------------------------------------------------------------------
+ def get_market_sentiment(self):
+ return {
+ "sentiment": "bullish",
+ "score": 0.72
+ }
+
+ def get_volume(self, token: str) -> float:
+ return 10000000.0 # mock volume for frontend usage
diff --git a/backend/migrate_add_contract_columns.py b/backend/migrate_add_contract_columns.py
new file mode 100644
index 0000000..7b11a2c
--- /dev/null
+++ b/backend/migrate_add_contract_columns.py
@@ -0,0 +1,42 @@
+"""
+One-time migration: Add smart-contract columns to the tournament table.
+
+Run with: python migrate_add_contract_columns.py
+"""
+
+import asyncio
+import os
+from dotenv import load_dotenv
+import asyncpg
+
+load_dotenv()
+
+DATABASE_URL = os.getenv("DATABASE_URL", "")
+
+# Convert SQLAlchemy URL to raw asyncpg URL
+# "postgresql+asyncpg://..." → "postgresql://..."
+PG_URL = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
+
+STATEMENTS = [
+ "ALTER TABLE tournament ADD COLUMN IF NOT EXISTS contract_tournament_id INTEGER;",
+ "ALTER TABLE tournament ADD COLUMN IF NOT EXISTS agent_contract_mapping JSONB DEFAULT '{}';",
+ "ALTER TABLE tournament ADD COLUMN IF NOT EXISTS betting_closed BOOLEAN DEFAULT FALSE;",
+ # Index to match the SQLAlchemy model
+ "CREATE INDEX IF NOT EXISTS ix_tournament_contract_tournament_id ON tournament (contract_tournament_id);",
+]
+
+
+async def main():
+ print(f"Connecting to database...")
+ conn = await asyncpg.connect(PG_URL, ssl="require")
+ try:
+ for stmt in STATEMENTS:
+ print(f" Running: {stmt}")
+ await conn.execute(stmt)
+ print("\nMigration complete! All 3 columns added to tournament table.")
+ finally:
+ await conn.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..52cacbf
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.pyright]
+include = ["app"]
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 67fdb87..933c2f0 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -89,6 +89,7 @@ MarkupSafe==3.0.3
mdurl==0.1.2
multidict==6.7.0
nest-asyncio==1.6.0
+ngrok==1.4.0
nilql==0.0.0a13
numpy==1.26.4
openai==1.109.1
@@ -160,7 +161,7 @@ tzdata==2025.2
uritemplate==4.2.0
urllib3==2.3.0
uvicorn==0.38.0
-uvloop==0.22.1
+# uvloop==0.22.1 # Linux/macOS only - not compatible with Windows
vine==5.1.0
watchfiles==1.1.1
wcwidth==0.2.14
diff --git a/backend/tests/test_bets.py b/backend/tests/test_bets.py
index 6b6195b..10f70db 100644
--- a/backend/tests/test_bets.py
+++ b/backend/tests/test_bets.py
@@ -94,7 +94,7 @@ async def test_get_bet(client, test_db):
r = await client.get(f"/bets/{bet.id}")
assert r.status_code == 200
- assert r.json()["amount"] == "150.00"
+ assert r.json()["amount"] == "150.00000000"
@pytest.mark.anyio
@@ -134,7 +134,7 @@ async def test_create_bet(client, test_db, mock_admin):
r = await client.post("/bets/", json=body)
assert r.status_code == 201, r.text
created = r.json()
- assert created["amount"] == "250.00"
+ assert created["amount"] == "250.00000000"
assert "id" in created
@@ -173,7 +173,7 @@ async def test_update_bet(client, test_db):
r = await client.put(f"/bets/{bet.id}", json={"settled": True, "payout": 200.0})
assert r.status_code == 200, r.text
assert r.json()["settled"] is True
- assert r.json()["payout"] == "200.00"
+ assert r.json()["payout"] == "200.00000000"
@pytest.mark.anyio
@@ -211,7 +211,7 @@ async def test_settle_bet(client, test_db):
r = await client.patch(f"/bets/{bet.id}/settle?payout=250.0")
assert r.status_code == 200, r.text
assert r.json()["settled"] is True
- assert r.json()["payout"] == "250.00"
+ assert r.json()["payout"] == "250.00000000"
@pytest.mark.anyio
diff --git a/backend/tests/test_make_trade_tool.py b/backend/tests/test_make_trade_tool.py
index 3f837b9..5baab8f 100644
--- a/backend/tests/test_make_trade_tool.py
+++ b/backend/tests/test_make_trade_tool.py
@@ -22,8 +22,6 @@ def mock_market_tool():
"""Create a mock market data tool with fixed prices."""
market_tool = Mock()
market_tool.get_price = Mock(side_effect=lambda token: {
- "WETH": 3000.0,
- "CBBTC": 60000.0,
"ETH": 3000.0,
"BTC": 60000.0,
}.get(token.upper(), None))
@@ -48,7 +46,7 @@ def portfolio_with_holdings():
return Portfolio(
agent_id="test_agent",
cash=200.0,
- holdings={"WETH": 0.1}, # 0.1 ETH worth ~$300
+ holdings={"ETH": 0.1}, # 0.1 ETH worth ~$300
starting_val=500.0,
holdings_val=300.0,
total_value=500.0,
@@ -90,57 +88,57 @@ class TestValidation:
"""Test trade validation logic."""
def test_valid_buy_trade(self, make_trade_tool):
- is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 100.0)
+ is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 100.0)
assert is_valid is True
assert reason == "Trade validated"
def test_valid_sell_trade(self, make_trade_tool_with_holdings):
- is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "WETH", 0.05)
+ is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "ETH", 0.05)
assert is_valid is True
assert reason == "Trade validated"
def test_invalid_action(self, make_trade_tool):
- is_valid, reason = make_trade_tool.validate_trade("HOLD", "WETH", 100.0)
+ is_valid, reason = make_trade_tool.validate_trade("HOLD", "ETH", 100.0)
assert is_valid is False
assert "Invalid action" in reason
def test_unsupported_token(self, make_trade_tool):
- is_valid, reason = make_trade_tool.validate_trade("BUY", "DOGE", 100.0)
+ is_valid, reason = make_trade_tool.validate_trade("BUY", "SHIB", 100.0)
assert is_valid is False
assert "Unsupported token" in reason
def test_insufficient_cash_for_buy(self, make_trade_tool):
- is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 600.0)
+ is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 600.0)
assert is_valid is False
assert "Insufficient cash" in reason
def test_insufficient_holdings_for_sell(self, make_trade_tool_with_holdings):
- is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "WETH", 1.0)
+ is_valid, reason = make_trade_tool_with_holdings.validate_trade("SELL", "ETH", 1.0)
assert is_valid is False
- assert "Insufficient WETH" in reason
+ assert "Insufficient ETH" in reason
def test_zero_amount(self, make_trade_tool):
- is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 0.0)
+ is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 0.0)
assert is_valid is False
assert "Invalid amount" in reason
def test_negative_amount(self, make_trade_tool):
- is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", -100.0)
+ is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", -100.0)
assert is_valid is False
assert "Invalid amount" in reason
def test_risk_limit_exceeded(self, make_trade_tool):
# With risk_score=0.5, max trade is 50% of $500 = $250
- is_valid, reason = make_trade_tool.validate_trade("BUY", "WETH", 300.0, risk_score=0.5)
+ is_valid, reason = make_trade_tool.validate_trade("BUY", "ETH", 300.0, risk_score=0.5)
assert is_valid is False
assert "exceeds risk limit" in reason
def test_case_insensitive_action(self, make_trade_tool):
- is_valid, _ = make_trade_tool.validate_trade("buy", "WETH", 100.0)
+ is_valid, _ = make_trade_tool.validate_trade("buy", "ETH", 100.0)
assert is_valid is True
def test_case_insensitive_token(self, make_trade_tool):
- is_valid, _ = make_trade_tool.validate_trade("BUY", "weth", 100.0)
+ is_valid, _ = make_trade_tool.validate_trade("BUY", "eth", 100.0)
assert is_valid is True
@@ -156,7 +154,7 @@ class TestBuyTrades:
async def test_basic_buy(self, make_trade_tool):
trade = await make_trade_tool.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=150.0, # $150 USDC
confidence=0.8,
summary="Test buy",
@@ -164,7 +162,7 @@ async def test_basic_buy(self, make_trade_tool):
# Check trade details
assert trade.action == "BUY"
- assert trade.token == "WETH"
+ assert trade.token == "ETH"
assert trade.price == 3000.0
assert trade.qty == pytest.approx(0.05) # $150 / $3000 = 0.05 ETH
assert trade.confidence == 0.8
@@ -173,43 +171,43 @@ async def test_basic_buy(self, make_trade_tool):
# Check portfolio updates
assert make_trade_tool.portfolio.cash == pytest.approx(350.0) # $500 - $150
- assert make_trade_tool.portfolio.holdings["WETH"] == pytest.approx(0.05)
+ assert make_trade_tool.portfolio.holdings["ETH"] == pytest.approx(0.05)
assert make_trade_tool.portfolio.num_trades == 1
@pytest.mark.asyncio
async def test_buy_adds_to_existing_holdings(self, make_trade_tool_with_holdings):
await make_trade_tool_with_holdings.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=60.0, # $60 USDC = 0.02 ETH
confidence=0.7,
summary="Add to position",
)
# Should add to existing 0.1 ETH
- assert make_trade_tool_with_holdings.portfolio.holdings["WETH"] == pytest.approx(0.12)
+ assert make_trade_tool_with_holdings.portfolio.holdings["ETH"] == pytest.approx(0.12)
assert make_trade_tool_with_holdings.portfolio.cash == pytest.approx(140.0) # $200 - $60
@pytest.mark.asyncio
async def test_buy_different_token(self, make_trade_tool_with_holdings):
await make_trade_tool_with_holdings.execute_trade(
action="BUY",
- token="CBBTC",
+ token="BTC",
amount=60.0, # $60 USDC
confidence=0.75,
summary="Diversify into BTC",
)
# Should have both tokens
- assert "WETH" in make_trade_tool_with_holdings.portfolio.holdings
- assert "CBBTC" in make_trade_tool_with_holdings.portfolio.holdings
- assert make_trade_tool_with_holdings.portfolio.holdings["CBBTC"] == pytest.approx(0.001) # $60 / $60000
+ assert "ETH" in make_trade_tool_with_holdings.portfolio.holdings
+ assert "BTC" in make_trade_tool_with_holdings.portfolio.holdings
+ assert make_trade_tool_with_holdings.portfolio.holdings["BTC"] == pytest.approx(0.001) # $60 / $60000
@pytest.mark.asyncio
async def test_buy_updates_metrics(self, make_trade_tool):
await make_trade_tool.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=100.0,
confidence=0.8,
summary="Test",
@@ -218,7 +216,7 @@ async def test_buy_updates_metrics(self, make_trade_tool):
status = make_trade_tool.get_portfolio_status()
assert status["num_trades"] == 1
assert status["cash"] == pytest.approx(400.0)
- assert "WETH" in status["holdings"]
+ assert "ETH" in status["holdings"]
# ============================================================================
@@ -233,7 +231,7 @@ class TestSellTrades:
async def test_basic_sell(self, make_trade_tool_with_holdings):
trade = await make_trade_tool_with_holdings.execute_trade(
action="SELL",
- token="WETH",
+ token="ETH",
amount=0.05, # Sell half (0.05 of 0.1 ETH)
confidence=0.75,
summary="Take profit",
@@ -241,26 +239,26 @@ async def test_basic_sell(self, make_trade_tool_with_holdings):
# Check trade details
assert trade.action == "SELL"
- assert trade.token == "WETH"
+ assert trade.token == "ETH"
assert trade.qty == 0.05
assert trade.price == 3000.0
# Check portfolio updates
- assert make_trade_tool_with_holdings.portfolio.holdings["WETH"] == pytest.approx(0.05)
+ assert make_trade_tool_with_holdings.portfolio.holdings["ETH"] == pytest.approx(0.05)
assert make_trade_tool_with_holdings.portfolio.cash == pytest.approx(350.0) # $200 + $150
@pytest.mark.asyncio
async def test_sell_all_removes_from_holdings(self, make_trade_tool_with_holdings):
await make_trade_tool_with_holdings.execute_trade(
action="SELL",
- token="WETH",
+ token="ETH",
amount=0.1, # Sell all
confidence=0.9,
summary="Exit position",
)
# Holdings should be empty for WETH
- assert "WETH" not in make_trade_tool_with_holdings.portfolio.holdings
+ assert "ETH" not in make_trade_tool_with_holdings.portfolio.holdings
assert make_trade_tool_with_holdings.portfolio.cash == pytest.approx(500.0) # $200 + $300
@pytest.mark.asyncio
@@ -268,7 +266,7 @@ async def test_sell_calculates_realized_pnl(self, make_trade_tool):
# First buy at $3000
await make_trade_tool.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=300.0, # Buy 0.1 ETH at $3000
confidence=0.8,
summary="Initial buy",
@@ -280,7 +278,7 @@ async def test_sell_calculates_realized_pnl(self, make_trade_tool):
# Sell at higher price
trade = await make_trade_tool.execute_trade(
action="SELL",
- token="WETH",
+ token="ETH",
amount=0.1, # Sell all
confidence=0.9,
summary="Take profit",
@@ -296,7 +294,7 @@ async def test_sell_losing_trade(self, make_trade_tool):
# First buy at $3000
await make_trade_tool.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=300.0,
confidence=0.8,
summary="Initial buy",
@@ -308,7 +306,7 @@ async def test_sell_losing_trade(self, make_trade_tool):
# Sell at lower price
trade = await make_trade_tool.execute_trade(
action="SELL",
- token="WETH",
+ token="ETH",
amount=0.1,
confidence=0.6,
summary="Cut losses",
@@ -331,19 +329,19 @@ class TestFIFOPnL:
@pytest.mark.asyncio
async def test_fifo_multiple_buys(self, make_trade_tool):
# Buy 1: 0.05 ETH at $3000
- await make_trade_tool.execute_trade("BUY", "WETH", 150.0, 0.8, "First buy")
+ await make_trade_tool.execute_trade("BUY", "ETH", 150.0, 0.8, "First buy")
# Change price to $3200
make_trade_tool.market_tool.get_price = Mock(return_value=3200.0)
# Buy 2: 0.05 ETH at $3200
- await make_trade_tool.execute_trade("BUY", "WETH", 160.0, 0.8, "Second buy")
+ await make_trade_tool.execute_trade("BUY", "ETH", 160.0, 0.8, "Second buy")
# Change price to $3500
make_trade_tool.market_tool.get_price = Mock(return_value=3500.0)
# Sell first lot (FIFO - should use $3000 cost basis)
- trade = await make_trade_tool.execute_trade("SELL", "WETH", 0.05, 0.9, "Sell first lot")
+ trade = await make_trade_tool.execute_trade("SELL", "ETH", 0.05, 0.9, "Sell first lot")
# PnL: (3500 - 3000) * 0.05 = $25
assert trade.realized_pnl == pytest.approx(25.0)
@@ -351,19 +349,19 @@ async def test_fifo_multiple_buys(self, make_trade_tool):
@pytest.mark.asyncio
async def test_fifo_partial_lot(self, make_trade_tool):
# Buy 0.1 ETH at $3000
- await make_trade_tool.execute_trade("BUY", "WETH", 300.0, 0.8, "Initial buy")
+ await make_trade_tool.execute_trade("BUY", "ETH", 300.0, 0.8, "Initial buy")
# Change price to $3300
make_trade_tool.market_tool.get_price = Mock(return_value=3300.0)
# Sell half
- trade = await make_trade_tool.execute_trade("SELL", "WETH", 0.05, 0.85, "Partial sell")
+ trade = await make_trade_tool.execute_trade("SELL", "ETH", 0.05, 0.85, "Partial sell")
# PnL: (3300 - 3000) * 0.05 = $15
assert trade.realized_pnl == pytest.approx(15.0)
# Sell remaining half
- trade2 = await make_trade_tool.execute_trade("SELL", "WETH", 0.05, 0.85, "Sell rest")
+ trade2 = await make_trade_tool.execute_trade("SELL", "ETH", 0.05, 0.85, "Sell rest")
# PnL: (3300 - 3000) * 0.05 = $15
assert trade2.realized_pnl == pytest.approx(15.0)
@@ -380,7 +378,7 @@ class TestPortfolioMetrics:
@pytest.mark.asyncio
async def test_roi_calculation(self, make_trade_tool):
# Buy some ETH
- await make_trade_tool.execute_trade("BUY", "WETH", 250.0, 0.8, "Buy")
+ await make_trade_tool.execute_trade("BUY", "ETH", 250.0, 0.8, "Buy")
# Price goes up 10%
make_trade_tool.market_tool.get_price = Mock(return_value=3300.0)
@@ -394,13 +392,13 @@ async def test_roi_calculation(self, make_trade_tool):
@pytest.mark.asyncio
async def test_win_rate_calculation(self, make_trade_tool):
# Execute trades
- await make_trade_tool.execute_trade("BUY", "WETH", 150.0, 0.8, "Buy 1")
+ await make_trade_tool.execute_trade("BUY", "ETH", 150.0, 0.8, "Buy 1")
make_trade_tool.market_tool.get_price = Mock(return_value=3500.0)
- await make_trade_tool.execute_trade("SELL", "WETH", 0.025, 0.9, "Win") # Winner
+ await make_trade_tool.execute_trade("SELL", "ETH", 0.025, 0.9, "Win") # Winner
make_trade_tool.market_tool.get_price = Mock(return_value=2800.0)
- await make_trade_tool.execute_trade("SELL", "WETH", 0.025, 0.6, "Loss") # Loser
+ await make_trade_tool.execute_trade("SELL", "ETH", 0.025, 0.6, "Loss") # Loser
# 1 win, 1 loss out of 3 trades (1 buy + 2 sells)
# But win_rate is based on winning vs losing trades only
@@ -419,7 +417,7 @@ def test_get_portfolio_status(self, make_trade_tool_with_holdings):
assert "win_rate" in status
assert status["cash"] == 200.0
- assert status["holdings"]["WETH"] == 0.1
+ assert status["holdings"]["ETH"] == 0.1
# ============================================================================
@@ -435,7 +433,7 @@ async def test_validation_error_raises_exception(self, make_trade_tool):
with pytest.raises(MakeTradeToolError) as exc_info:
await make_trade_tool.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=1000.0, # More than available
confidence=0.8,
summary="Should fail",
@@ -449,7 +447,7 @@ async def test_price_fetch_error(self, make_trade_tool):
with pytest.raises(MakeTradeToolError) as exc_info:
await make_trade_tool.execute_trade(
action="BUY",
- token="WETH",
+ token="ETH",
amount=100.0,
confidence=0.8,
summary="Should fail",
@@ -480,7 +478,7 @@ async def test_trade_saved_to_database(self, base_portfolio, mock_market_tool):
tournament_uuid=uuid4(),
)
- await tool.execute_trade("BUY", "WETH", 100.0, 0.8, "Test")
+ await tool.execute_trade("BUY", "ETH", 100.0, 0.8, "Test")
# Verify database was called
mock_db.save_trade.assert_called_once()
@@ -501,6 +499,6 @@ async def test_database_error_doesnt_fail_trade(self, base_portfolio, mock_marke
)
# Trade should still succeed even if DB fails
- trade = await tool.execute_trade("BUY", "WETH", 100.0, 0.8, "Test")
+ trade = await tool.execute_trade("BUY", "ETH", 100.0, 0.8, "Test")
assert trade is not None
assert tool.portfolio.cash == pytest.approx(400.0)
diff --git a/backend/tests/tests.http b/backend/tests/tests.http
new file mode 100644
index 0000000..fffc92e
--- /dev/null
+++ b/backend/tests/tests.http
@@ -0,0 +1,95 @@
+### ============================================
+### 🔥 BACKEND HEALTH CHECK
+### ============================================
+
+GET http://localhost:8000/
+Accept: application/json
+
+
+### ============================================
+### 🔥 RUNTIME AGENTS — LIST ALL
+### ============================================
+
+GET http://localhost:8000/runtime/agents/
+Accept: application/json
+
+
+### ============================================
+### 🔥 RUNTIME AGENT PORTFOLIO
+### ============================================
+
+GET http://localhost:8000/runtime/agents/1/portfolio
+Accept: application/json
+
+###
+GET http://localhost:8000/runtime/agents/2/portfolio
+Accept: application/json
+
+
+### ============================================
+### 🔥 RUNTIME AGENT TRADES
+### ============================================
+
+GET http://localhost:8000/runtime/agents/1/trades
+Accept: application/json
+
+###
+GET http://localhost:8000/runtime/agents/2/trades
+Accept: application/json
+
+
+### ============================================
+### 🔥 RUNTIME MARKET PRICES
+### ============================================
+
+GET http://localhost:8000/runtime/agents/market/prices
+Accept: application/json
+
+
+### ============================================
+### 🔥 TOURNAMENT API (if DB enabled)
+### ============================================
+
+GET http://localhost:8000/tournaments/
+Accept: application/json
+
+###
+GET http://localhost:8000/tournaments/1
+Accept: application/json
+
+
+### ============================================
+### 🔥 AGENTS API (DB agents, NOT runtime agents)
+### ============================================
+
+GET http://localhost:8000/agents/
+Accept: application/json
+
+###
+GET http://localhost:8000/agents/1
+Accept: application/json
+
+
+### ============================================
+### 🔥 TRADES API (DB trades)
+### ============================================
+
+GET http://localhost:8000/trades/
+Accept: application/json
+
+
+### ============================================
+### 🔥 BETS API
+### ============================================
+
+GET http://localhost:8000/bets/
+Accept: application/json
+
+
+### ============================================
+### 🔥 MARKET DATA (DB-driven)
+### ============================================
+
+GET http://localhost:8000/market-data/prices
+Accept: application/json
+
diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx
new file mode 100644
index 0000000..e34dc58
--- /dev/null
+++ b/frontend/app/admin/layout.tsx
@@ -0,0 +1,165 @@
+"use client";
+
+import AdminSidebar from "@/app/components/admin/layout/AdminSidebar";
+import Link from "next/link";
+import { useState } from "react";
+import CreateTournamentModal, { TournamentFormData } from "@/app/components/admin/tournaments/CreateTournamentModal";
+import CreateAgentModal, { AgentFormData } from "@/app/components/admin/agents/CreateAgentModal";
+import { useWalletAuth } from "@/src/hooks/useWalletAuth";
+import { ConnectWallet } from "@/src/components/wallet/ConnectWallet";
+import { useCreateTournament } from "@/src/hooks/useTournaments";
+import { useCreateAgent } from "@/src/hooks/useAgents";
+import { Loader2, ShieldAlert, ShieldX } from "lucide-react";
+import toast from "react-hot-toast";
+import "@/app/styles/datepicker.css";
+
+export default function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [isCreateTournamentModalOpen, setIsCreateTournamentModalOpen] = useState(false);
+ const [isCreateAgentModalOpen, setIsCreateAgentModalOpen] = useState(false);
+
+ const { isConnected, isAuthenticated, isSigningIn, signIn, isAdmin, address } = useWalletAuth();
+ const createTournament = useCreateTournament();
+ const createAgent = useCreateAgent();
+
+ const handleCreateTournament = async (data: TournamentFormData) => {
+ try {
+ await createTournament.mutateAsync({
+ name: data.name,
+ start_date: data.start_date.toISOString(),
+ end_date: data.end_date.toISOString(),
+ prize_pool: data.prize_pool,
+ agent_ids: [],
+ });
+ setIsCreateTournamentModalOpen(false);
+ toast.success("Tournament created");
+ } catch (err) {
+ toast.error((err as Error).message || "Failed to create tournament");
+ }
+ };
+
+ const handleCreateAgent = async (data: AgentFormData) => {
+ try {
+ await createAgent.mutateAsync({
+ name: data.name,
+ personality: data.personality,
+ strategy_type: data.strategy_type,
+ });
+ setIsCreateAgentModalOpen(false);
+ toast.success("Agent created");
+ } catch (err) {
+ toast.error((err as Error).message || "Failed to create agent");
+ }
+ };
+
+ // Gate 1: Wallet not connected
+ if (!isConnected) {
+ return (
+
+
+
+
Admin Access Required
+
Connect your admin wallet to access the dashboard.
+
+
+
+ );
+ }
+
+ // Gate 2: Wallet connected but not signed in
+ if (!isAuthenticated) {
+ return (
+
+
+
+
Sign In Required
+
+ Sign a message with your wallet to verify admin access.
+
+
+ {isSigningIn ? (
+ <>
+
+ Signing...
+ >
+ ) : (
+ "Sign In to Admin"
+ )}
+
+
+
+ );
+ }
+
+ // Gate 3: Signed in but not admin role
+ if (!isAdmin) {
+ return (
+
+
+
+
Access Denied
+
+ Wallet {address} does not have admin privileges.
+
+
+ Back to Home
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Animated gradient glow effect */}
+
+
+
+ {/* Checkered pattern overlay */}
+
+
+ {/* Sidebar */}
+
+
setIsCreateTournamentModalOpen(true)}
+ onCreateAgent={() => setIsCreateAgentModalOpen(true)}
+ />
+
+
+ {/* Main Content */}
+
+
+ {children}
+
+
+
+
setIsCreateTournamentModalOpen(false)}
+ onSubmit={handleCreateTournament}
+ />
+
+ setIsCreateAgentModalOpen(false)}
+ onSubmit={handleCreateAgent}
+ />
+
+ );
+}
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
new file mode 100644
index 0000000..839a06f
--- /dev/null
+++ b/frontend/app/admin/page.tsx
@@ -0,0 +1,842 @@
+"use client";
+
+import { useState, useMemo, useEffect } from "react";
+import { Search, Link2, XCircle, Trophy, Ban, Loader2, CheckCircle, AlertCircle, Calendar, DollarSign, Users, Plus, X, ArrowRight, Play } from "lucide-react";
+import { motion, AnimatePresence } from "framer-motion";
+import { formatDistanceToNow } from "date-fns";
+import { useTournaments } from "@/src/hooks/useTournaments";
+import { useAgents } from "@/src/hooks/useAgents";
+import { useTournamentAgentStates } from "@/src/hooks/useAgentStates";
+import { findAgentById } from "@/src/util/findAgentById";
+import {
+ useCreateOnchainTournament,
+ useStartTournament,
+ useCloseBettingOnchain,
+ useSettleTournamentOnchain,
+ useCancelTournamentOnchain,
+} from "@/src/hooks/useAdminOnchain";
+import { useAuthStore } from "@/src/store/useAuthStore";
+
+// Helper to format dates
+const formatDate = (date: string) => {
+ return new Date(date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+};
+
+// Helper to format currency
+const formatCurrency = (value: string | number) => {
+ return `$${Number(value).toLocaleString()}`;
+};
+
+// Extract short, readable error message from contract/API errors
+const getShortError = (err: unknown): string => {
+ const raw = (err as Error)?.message || 'Unknown error';
+ // Try to extract "execution reverted: " pattern
+ const revertMatch = raw.match(/execution reverted:\s*([^"',]+)/i);
+ if (revertMatch) return revertMatch[1].trim();
+ // Strip hex data and long hashes
+ const cleaned = raw.replace(/0x[a-fA-F0-9]{10,}/g, '').replace(/\s{2,}/g, ' ').trim();
+ return cleaned.length > 80 ? cleaned.slice(0, 80) + '...' : cleaned;
+};
+
+// Generate DiceBear avatar URL
+const getAvatarUrl = (name: string) =>
+ `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(name)}`;
+
+export default function AdminDashboard() {
+ const { isAuthenticated, role } = useAuthStore();
+ const [agentSearch, setAgentSearch] = useState("");
+ const [tournamentFilter, setTournamentFilter] = useState<"all" | "upcoming" | "live" | "completed">("all");
+ const [selectedTournamentId, setSelectedTournamentId] = useState(null);
+ const [selectedAgentsForOnchain, setSelectedAgentsForOnchain] = useState([]);
+ const [winnerAgentId, setWinnerAgentId] = useState("");
+ const [actionMessage, setActionMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
+ const [showOnchainPanel, setShowOnchainPanel] = useState(false);
+
+ // Real API data
+ const { data: tournaments, isLoading: tournamentsLoading } = useTournaments();
+ const { data: agents, isLoading: agentsLoading } = useAgents();
+ const { data: agentStates } = useTournamentAgentStates(selectedTournamentId || "");
+
+ // On-chain mutations
+ const createOnchain = useCreateOnchainTournament();
+ const startTournament = useStartTournament();
+ const closeBetting = useCloseBettingOnchain();
+ const settleTournament = useSettleTournamentOnchain();
+ const cancelTournament = useCancelTournamentOnchain();
+
+ const isLoading = tournamentsLoading || agentsLoading;
+
+ // Lock body scroll when panel is open
+ useEffect(() => {
+ document.body.style.overflow = showOnchainPanel ? "hidden" : "";
+ return () => { document.body.style.overflow = ""; };
+ }, [showOnchainPanel]);
+
+ // Filter tournaments by status
+ const filteredTournaments = useMemo(() => {
+ if (!tournaments) return [];
+ if (tournamentFilter === "all") return tournaments;
+ return tournaments.filter(t => t.status?.toLowerCase() === tournamentFilter);
+ }, [tournaments, tournamentFilter]);
+
+ // Filter agents by search
+ const filteredAgents = useMemo(() => {
+ if (!agents) return [];
+ if (!agentSearch) return agents;
+ return agents.filter(agent =>
+ agent.name.toLowerCase().includes(agentSearch.toLowerCase())
+ );
+ }, [agents, agentSearch]);
+
+ // Available agents = all agents minus already-selected ones
+ const availableAgents = useMemo(() => {
+ if (!agents) return [];
+ return agents.filter(a => !selectedAgentsForOnchain.includes(String(a.id)));
+ }, [agents, selectedAgentsForOnchain]);
+
+ // Assigned agents in order, with their data
+ const assignedAgents = useMemo(() => {
+ if (!agents) return [];
+ return selectedAgentsForOnchain
+ .map(id => findAgentById(agents, id))
+ .filter(Boolean) as typeof agents;
+ }, [agents, selectedAgentsForOnchain]);
+
+ const selectedTournament = tournaments?.find(t => t.id === selectedTournamentId);
+
+ // Add agent to assignment list
+ const addAgent = (agentId: string) => {
+ setSelectedAgentsForOnchain(prev => [...prev, agentId]);
+ };
+
+ // Remove agent from assignment list
+ const removeAgent = (agentId: string) => {
+ setSelectedAgentsForOnchain(prev => prev.filter(id => id !== agentId));
+ };
+
+ // Handle on-chain create
+ const handleCreateOnchain = async () => {
+ if (!selectedTournamentId || selectedAgentsForOnchain.length < 2) {
+ setActionMessage({ type: 'error', text: 'Select at least 2 agents to create on-chain tournament' });
+ return;
+ }
+
+ try {
+ const result = await createOnchain.mutateAsync({
+ tournamentId: selectedTournamentId,
+ agentIds: selectedAgentsForOnchain,
+ });
+ setActionMessage({ type: 'success', text: `Tournament linked on-chain! TX: ${result.tx_hash.slice(0, 10)}...` });
+ setSelectedAgentsForOnchain([]);
+ } catch (err) {
+ setActionMessage({ type: 'error', text: getShortError(err) });
+ }
+ };
+
+ // Handle start tournament
+ const handleStartTournament = async () => {
+ if (!selectedTournamentId) return;
+ try {
+ const result = await startTournament.mutateAsync(selectedTournamentId);
+ setActionMessage({ type: 'success', text: `Tournament started! ${result.agents_count} agents initialized.` });
+ } catch (err) {
+ setActionMessage({ type: 'error', text: getShortError(err) });
+ }
+ };
+
+ // Handle close betting
+ const handleCloseBetting = async () => {
+ if (!selectedTournamentId) return;
+ try {
+ const result = await closeBetting.mutateAsync(selectedTournamentId);
+ setActionMessage({ type: 'success', text: `Betting closed! TX: ${result.tx_hash.slice(0, 10)}...` });
+ } catch (err) {
+ setActionMessage({ type: 'error', text: getShortError(err) });
+ }
+ };
+
+ // Handle settle tournament
+ const handleSettleTournament = async () => {
+ if (!selectedTournamentId || !winnerAgentId) {
+ setActionMessage({ type: 'error', text: 'Select a winner agent to settle' });
+ return;
+ }
+ try {
+ const result = await settleTournament.mutateAsync({
+ tournamentId: selectedTournamentId,
+ winnerAgentId,
+ });
+ setActionMessage({ type: 'success', text: `Tournament settled! TX: ${result.tx_hash.slice(0, 10)}...` });
+ setWinnerAgentId("");
+ } catch (err) {
+ setActionMessage({ type: 'error', text: getShortError(err) });
+ }
+ };
+
+ // Handle cancel tournament
+ const handleCancelTournament = async () => {
+ if (!selectedTournamentId) return;
+ if (!confirm("Are you sure you want to cancel this tournament? All bets will be refundable.")) return;
+ try {
+ const result = await cancelTournament.mutateAsync(selectedTournamentId);
+ setActionMessage({ type: 'success', text: `Tournament cancelled! TX: ${result.tx_hash.slice(0, 10)}...` });
+ } catch (err) {
+ setActionMessage({ type: 'error', text: getShortError(err) });
+ }
+ };
+
+ const anyMutationPending = createOnchain.isPending || startTournament.isPending || closeBetting.isPending || settleTournament.isPending || cancelTournament.isPending;
+
+ const getStatusBadge = (status: string) => {
+ const statusLower = status?.toLowerCase() || 'upcoming';
+ const colors: Record = {
+ upcoming: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
+ live: 'bg-green-500/20 text-green-400 border-green-500/30',
+ completed: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
+ };
+ return colors[statusLower] || colors.upcoming;
+ };
+
+ // Auth gate - only admins can access this page
+ if (!isAuthenticated || role !== "admin") {
+ return (
+
+
+
+
Admin Access Required
+
Please sign in with an admin wallet to access this page.
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+ Loading data...
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
Admin Dashboard
+
Manage tournaments, agents, and on-chain operations
+
+
+ {/* Action Message */}
+
+ {actionMessage && (
+
+ {actionMessage.type === 'success' ? : }
+ {actionMessage.text}
+ setActionMessage(null)} className="ml-auto text-white/50 hover:text-white text-xl">×
+
+ )}
+
+
+ {/* ============ TOURNAMENTS SECTION ============ */}
+
+
+
+
Tournaments
+
View and manage all tournaments
+
+
+ {tournaments?.length || 0} total
+
+
+
+ {/* Filter Buttons */}
+
+ {['all', 'upcoming', 'live', 'completed'].map((filter) => (
+ setTournamentFilter(filter as typeof tournamentFilter)}
+ className={`px-4 py-2 rounded-lg font-medium transition-all capitalize ${
+ tournamentFilter === filter
+ ? filter === 'all' ? 'bg-white/10 text-white border border-white/20' :
+ filter === 'live' ? 'bg-green-500/20 text-green-400 border border-green-500/30' :
+ filter === 'upcoming' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
+ 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
+ : 'text-gray-400 hover:bg-white/5'
+ }`}
+ >
+ {filter}
+
+ ))}
+
+
+ {/* Tournament Cards */}
+
+ {filteredTournaments.map((tournament) => {
+ const agentCount = tournament.agent_contract_mapping
+ ? Object.keys(tournament.agent_contract_mapping).length
+ : 0;
+ const now = new Date();
+ const startDate = new Date(tournament.start_date);
+ const endDate = new Date(tournament.end_date);
+ const isUpcoming = tournament.status?.toLowerCase() === 'upcoming';
+ const isLive = tournament.status?.toLowerCase() === 'live';
+ const isCompleted = tournament.status?.toLowerCase() === 'completed';
+
+ // Time display
+ let timeDisplay = '';
+ if (isUpcoming && startDate > now) {
+ timeDisplay = `Starts ${formatDistanceToNow(startDate, { addSuffix: true })}`;
+ } else if (isLive && endDate > now) {
+ timeDisplay = `Ends ${formatDistanceToNow(endDate, { addSuffix: true })}`;
+ } else if (isCompleted) {
+ timeDisplay = `Ended ${formatDistanceToNow(endDate, { addSuffix: true })}`;
+ }
+
+ return (
+
{
+ setSelectedTournamentId(tournament.id);
+ setSelectedAgentsForOnchain([]);
+ setShowOnchainPanel(true);
+ }}
+ >
+
+ {/* Left: Tournament Info */}
+
+
+
{tournament.name}
+
+ {tournament.status}
+
+ {tournament.contract_tournament_id ? (
+
+ On-chain #{tournament.contract_tournament_id}
+
+ ) : (
+
+ Not linked
+
+ )}
+
+
+ {/* Time Status */}
+ {timeDisplay && (
+
+ {timeDisplay}
+
+ )}
+
+
+
+
+ {formatDate(tournament.start_date)} - {formatDate(tournament.end_date)}
+
+
+
+ {formatCurrency(tournament.prize_pool)} prize
+
+
+
+ {agentCount} agents
+
+
+
+
+ {/* Right: Stats */}
+
+
{formatCurrency(tournament.prize_pool)}
+
Prize Pool
+ {tournament.winner_agent_id && (
+
+ )}
+
+
+
+ {/* Agent Mapping Info */}
+ {tournament.agent_contract_mapping && Object.keys(tournament.agent_contract_mapping).length > 0 && (
+
+
Mapped Agents ({Object.keys(tournament.agent_contract_mapping).length}):
+
+ {Object.entries(tournament.agent_contract_mapping).slice(0, 6).map(([agentId, contractId]) => {
+ const agent = findAgentById(agents, agentId);
+ return (
+
+
+
{agent?.name || agentId.slice(0, 8)}
+
#{contractId}
+
+ );
+ })}
+ {Object.keys(tournament.agent_contract_mapping).length > 6 && (
+
+ +{Object.keys(tournament.agent_contract_mapping).length - 6} more
+
+ )}
+
+
+ )}
+
+ );
+ })}
+
+ {filteredTournaments.length === 0 && (
+
+
No tournaments found for this filter.
+
+ )}
+
+
+
+ {/* ============ AGENTS SECTION ============ */}
+
+
+
+
Agents
+
Manage and view all AI trading agents
+
+
{agents?.length || 0} total
+
+
+ {/* Search Bar */}
+
+
+ setAgentSearch(e.target.value)}
+ className="w-full bg-yellow-500/90 backdrop-blur-sm text-slate-900 placeholder:text-slate-700 rounded-xl pl-12 pr-4 py-3 font-medium focus:outline-none focus:ring-2 focus:ring-yellow-400"
+ />
+
+
+ {/* Agent Grid */}
+
+ {filteredAgents.map((agent, index) => {
+ // Extract stats from the agent's stats JSON field
+ const stats = agent.stats as Record
|| {};
+ const totalTournaments = stats.total_tournaments ?? 0;
+ const wins = stats.wins ?? 0;
+ const winRate = stats.win_rate ?? 0;
+ const avgRank = stats.avg_rank ?? 0;
+ const totalTrades = stats.total_trades ?? 0;
+
+ return (
+
+ {/* Header: Avatar + Name */}
+
+
+
+
+
+
{agent.name}
+
{agent.strategy_type?.replace('_', ' ') || 'Agent'}
+
+
+
+ {/* Personality */}
+
+
Personality
+
{agent.personality || 'No personality defined'}
+
+
+ {/* Stats Grid */}
+
+
+
Tournaments
+
{totalTournaments}
+
+
+
+
Win Rate
+
{(winRate * 100).toFixed(0)}%
+
+
+
Avg Rank
+
{avgRank > 0 ? `#${avgRank.toFixed(1)}` : '-'}
+
+
+
+ {/* Additional Stats */}
+
+ Total Trades: {totalTrades}
+ ID: {agent.id.slice(0, 8)}...
+
+
+ );
+ })}
+
+ {filteredAgents.length === 0 && (
+ No agents found
+ )}
+
+
+
+ {/* ============ ON-CHAIN ACTIONS PANEL (Slide-in) ============ */}
+
+ {showOnchainPanel && selectedTournament && (
+ <>
+ {/* Backdrop */}
+ setShowOnchainPanel(false)}
+ className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
+ />
+
+ {/* Panel */}
+
+
+ {/* Header */}
+
+
On-Chain Actions
+ setShowOnchainPanel(false)}
+ className="p-2 hover:bg-white/10 rounded-lg transition"
+ >
+
+
+
+
+ {/* Read-only Tournament Summary */}
+
+
{selectedTournament.name}
+
+
+ Status:
+
+ {selectedTournament.status}
+
+
+
+ Prize Pool:
+ {formatCurrency(selectedTournament.prize_pool)}
+
+
+ Dates:
+ {formatDate(selectedTournament.start_date)} - {formatDate(selectedTournament.end_date)}
+
+ {selectedTournament.contract_tournament_id != null && (
+
+ Contract ID:
+
+
+ #{selectedTournament.contract_tournament_id}
+
+
+ )}
+
+
+
+ {/* Actions */}
+
+
+ {/* ===== NOT LINKED: Agent Assignment + Create ===== */}
+ {!selectedTournament.contract_tournament_id && (
+
+
+ Link Tournament On-Chain
+
+
+ {/* Assigned Agents (right side concept) */}
+
+
+ Assigned Agents ({assignedAgents.length})
+ {assignedAgents.length < 2 && - need at least 2 }
+
+
+ {assignedAgents.length === 0 ? (
+
+ No agents assigned yet. Add agents below.
+
+ ) : (
+
+ {assignedAgents.map((agent, idx) => (
+
+
+ #{idx + 1}
+
+
+
{agent.name}
+
{agent.strategy_type?.replace('_', ' ')}
+
removeAgent(agent.id)}
+ className="p-1 hover:bg-red-500/20 rounded transition"
+ title="Remove agent"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Available Agents (left side concept) */}
+ {availableAgents.length > 0 && (
+
+
Available Agents
+
+ {availableAgents.map(agent => (
+
+
+
{agent.name}
+
{agent.strategy_type?.replace('_', ' ')}
+
addAgent(agent.id)}
+ className="p-1 hover:bg-blue-500/20 rounded transition"
+ title="Add agent"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ {createOnchain.isPending ? (
+
+ ) : (
+
+ )}
+ Create On-Chain ({assignedAgents.length} agents)
+
+
+ )}
+
+ {/* ===== LINKED: Read-only mapping + Lifecycle actions ===== */}
+ {selectedTournament.contract_tournament_id != null && (
+ <>
+ {/* Read-only Agent Mapping */}
+ {selectedTournament.agent_contract_mapping && Object.keys(selectedTournament.agent_contract_mapping).length > 0 && (
+
+
+ On-Chain Tournament #{selectedTournament.contract_tournament_id}
+
+
Agent Mapping (read-only)
+
+ {Object.entries(selectedTournament.agent_contract_mapping)
+ .sort(([, a], [, b]) => a - b)
+ .map(([agentId, contractId]) => {
+ const agent = findAgentById(agents, agentId);
+ return (
+
+
#{contractId}
+
+
{agent?.name || agentId.slice(0, 8)}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Lifecycle Status Indicator */}
+
+
On-Chain Lifecycle
+
+ Linked
+ →
+
+ {selectedTournament.betting_closed ? 'Betting Closed' : 'Betting Open'}
+
+ →
+
+ {selectedTournament.winner_agent_id ? 'Settled' : 'Pending'}
+
+
+
+
+ {/* Start Tournament - only show if linked but not yet live */}
+ {selectedTournament.status?.toLowerCase() === 'upcoming' && (
+
+
+ Start Tournament
+
+
Initialize agents and set tournament to live.
+
+ {startTournament.isPending ? : }
+ Start Tournament
+
+
+ )}
+
+ {/* Close Betting - only show if betting is open, not settled, and not cancelled */}
+ {!selectedTournament.betting_closed && !selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() !== 'completed' && (
+
+
+ Close Betting
+
+
Stop accepting new bets.
+
+ {closeBetting.isPending ? : }
+ Close Betting
+
+
+ )}
+
+ {/* Settle Tournament - only show after betting closed, not yet settled, and not cancelled */}
+ {selectedTournament.betting_closed && !selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() !== 'completed' && (
+
+
+ Settle Tournament
+
+
Select the winning agent:
+
+
setWinnerAgentId(e.target.value)}
+ className="w-full mb-3 p-2 rounded-lg bg-slate-800 border border-white/20 text-white [&>option]:bg-slate-800 [&>option]:text-white"
+ >
+ Select winner...
+ {agentStates?.map(state => {
+ const agent = findAgentById(agents, state.agent_id);
+ return (
+
+ {agent?.name || state.agent_id} - ${parseFloat(state.portfolio_value_usd).toLocaleString()}
+
+ );
+ })}
+ {/* Fallback: show mapped agents if no states */}
+ {(!agentStates || agentStates.length === 0) && selectedTournament.agent_contract_mapping &&
+ Object.keys(selectedTournament.agent_contract_mapping).map(agentId => {
+ const agent = findAgentById(agents, agentId);
+ return (
+
+ {agent?.name || agentId.slice(0, 8)}
+
+ );
+ })
+ }
+
+
+
+ {settleTournament.isPending ? : }
+ Settle with Winner
+
+
+ )}
+
+ {/* Cancel Tournament - only show if not yet settled and not already cancelled */}
+ {!selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() !== 'completed' && (
+
+
+ Cancel Tournament
+
+
Cancel and allow bet refunds.
+
+ {cancelTournament.isPending ? : }
+ Cancel Tournament
+
+
+ )}
+
+ {/* Settled indicator */}
+ {selectedTournament.winner_agent_id && (
+
+
+
Tournament Settled
+
Winner has been declared and payouts are available.
+
+ )}
+
+ {/* Cancelled indicator */}
+ {!selectedTournament.winner_agent_id && selectedTournament.status?.toLowerCase() === 'completed' && (
+
+
+
Tournament Cancelled
+
This tournament was cancelled. Bets are refundable.
+
+ )}
+ >
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/app/components/CenteredAgentCarousel.tsx b/frontend/app/components/CenteredAgentCarousel.tsx
index 7805b78..b2e13fb 100644
--- a/frontend/app/components/CenteredAgentCarousel.tsx
+++ b/frontend/app/components/CenteredAgentCarousel.tsx
@@ -21,6 +21,10 @@ export default function CenteredAgentCarousel({
const frameRef = useRef(null);
const startRef = useRef(0);
+ const lastProgressUpdate = useRef(0);
+
+ // Use a ref for goNext to avoid re-triggering the RAF effect
+ const goNextRef = useRef<() => void>(() => {});
// -------------------------
// Navigation Handlers
@@ -30,6 +34,7 @@ export default function CenteredAgentCarousel({
setActive((prev) => {
const next = (prev + 1) % items.length;
startRef.current = performance.now();
+ lastProgressUpdate.current = 0;
setProgress(0);
return next;
});
@@ -39,48 +44,55 @@ export default function CenteredAgentCarousel({
setActive((prev) => {
const next = (prev - 1 + items.length) % items.length;
startRef.current = performance.now();
+ lastProgressUpdate.current = 0;
setProgress(0);
return next;
});
}, [items.length]);
+ // Keep ref in sync
+ useEffect(() => {
+ goNextRef.current = goNext;
+ }, [goNext]);
+
// -------------------------
// Autorotation Animation
// -------------------------
- const animate = useCallback(
- (now: number) => {
+ useEffect(() => {
+ const animate = (now: number) => {
const elapsed = now - startRef.current;
const pct = Math.min(elapsed / autoRotateMs, 1);
- setProgress(pct * 100);
+ // Throttle state updates to ~10fps to reduce re-renders
+ if (now - lastProgressUpdate.current > 100 || pct >= 1) {
+ lastProgressUpdate.current = now;
+ setProgress(pct * 100);
+ }
if (pct < 1) {
frameRef.current = requestAnimationFrame(animate);
} else {
- goNext();
+ goNextRef.current();
}
- },
- [autoRotateMs, goNext]
- );
+ };
- useEffect(() => {
startRef.current = performance.now();
+ lastProgressUpdate.current = 0;
frameRef.current = requestAnimationFrame(animate);
- // ✔ FIXED CLEANUP — always returns void
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
};
- }, [active, animate]);
+ }, [active, autoRotateMs]);
// -------------------------
// Swipe Gesture Navigation
// -------------------------
- const onSwipeEnd = (_: any, info: PanInfo) => {
+ const onSwipeEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (info.offset.x > 80 || info.velocity.x > 300) {
goPrev();
} else if (info.offset.x < -80 || info.velocity.x < -300) {
@@ -114,7 +126,7 @@ export default function CenteredAgentCarousel({
{/* -------------------------
TOP — CENTERED AGENT CARD
------------------------- */}
-
+
= {
+ "/about": "About",
+ "/docs": "Documentation",
+ "/faq": "FAQ",
+ "/terms": "Terms of Service",
+};
+
+export function ConditionalLayout({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+
+ // Check if we're on an admin route
+ const isAdminRoute = pathname?.startsWith("/admin");
+
+ // Check if we're on the dashboard/home route
+ const isDashboardRoute = pathname === "/home";
+
+ // Check if we're on an informational page
+ const infoTitle = pathname ? INFO_PAGES[pathname] : undefined;
+
+ if (isAdminRoute) {
+ // Admin routes: no navbar/footer (admin has its own sidebar)
+ return <>{children}>;
+ }
+
+ if (isDashboardRoute) {
+ // Dashboard route: use DashboardNavbar with section scroll
+ return (
+ <>
+
+ {children}
+
+ >
+ );
+ }
+
+ if (infoTitle) {
+ // Informational pages: simple navbar with page title + wallet connect
+ return (
+ <>
+
+ {children}
+
+ >
+ );
+ }
+
+ // Landing page and other routes: use LandingNavbar
+ return (
+ <>
+
+ {children}
+
+ >
+ );
+}
diff --git a/frontend/app/components/FeaturedAgents.tsx b/frontend/app/components/FeaturedAgents.tsx
index 1f8f710..c75d575 100644
--- a/frontend/app/components/FeaturedAgents.tsx
+++ b/frontend/app/components/FeaturedAgents.tsx
@@ -16,30 +16,13 @@ export default function FeaturedAgents() {
offset: ['start end', 'end start'],
});
- // Parallax background motion
- const y = useTransform(scrollYProgress, [0, 1], [100, -100]);
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
return (
- {/* Background pattern parallax */}
-
-
-
-
{/* Content */}
- {/* Subtle glow - moves slower (parallax) */}
-
-
-
-
- {/* Content - fades out as you scroll */}
-
- {/* Horizontal layout for quote + curve */}
-
- {/* Left: Quote */}
-
- Fantasy Football for AI Traders
-
-
- {/* Right: Parabolic Reveal - moves at different speed */}
-
-
-
-
-
-
- A place to engage, learn, and have fun. Watch your favorite AI traders
- compete, banter, and battle for the top spot.
-
-
- {/* Buttons container */}
-
-
- Watch Tournament
-
-
-
-
-
-
- );
-}
diff --git a/frontend/app/components/HowItWorks.tsx b/frontend/app/components/HowItWorks.tsx
index 1484634..b07976f 100644
--- a/frontend/app/components/HowItWorks.tsx
+++ b/frontend/app/components/HowItWorks.tsx
@@ -12,22 +12,13 @@ export default function HowItWorks() {
offset: ['start end', 'end start'],
});
- // Parallax transforms
- const y = useTransform(scrollYProgress, [0, 1], [150, -150]);
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
return (
- {/* Background gradient that moves with parallax */}
-
-
{/* Content */}
= {
+ AlphaBot: {
+ strategy: 'Momentum',
+ personality: 'Aggressive trend chaser focused on breakouts.',
+ },
+ TrendHunter: {
+ strategy: 'Trend Following',
+ personality: 'Rides winning moves until signal reversal.',
+ },
+ WhaleWatch: {
+ strategy: 'Liquidity Sniper',
+ personality: 'Tracks large flows to front-run momentum.',
+ },
+ MomentumX: {
+ strategy: 'High Velocity',
+ personality: 'Fast entries and exits, low patience.',
+ },
+ DeepValue: {
+ strategy: 'Value',
+ personality: 'Patient, waits for asymmetric value.',
+ },
+ SwingTrader: {
+ strategy: 'Swing',
+ personality: 'Holds for multi-day momentum shifts.',
+ },
+ VolBot: {
+ strategy: 'Volatility',
+ personality: 'Thrives on rapid swings and spikes.',
+ },
+ RiskMaster: {
+ strategy: 'Risk Parity',
+ personality: 'Balances risk across positions.',
+ },
+ SpeedDemon: {
+ strategy: 'Scalping',
+ personality: 'Takes tiny profits at high frequency.',
+ },
+ FlashTrader: {
+ strategy: 'Momentum',
+ personality: 'Explosive entries on fast moves.',
+ },
+ QuickSilver: {
+ strategy: 'Arbitrage',
+ personality: 'Finds tiny inefficiencies at speed.',
+ },
+ Champion: {
+ strategy: 'Balanced',
+ personality: 'Consistent, low drawdown approach.',
+ },
+ SilverStar: {
+ strategy: 'Mean Reversion',
+ personality: 'Buys dips, sells fades.',
+ },
+ BronzeBeast: {
+ strategy: 'Contrarian',
+ personality: 'Fades crowded trades with conviction.',
+ },
+};
+
+// Live countdown hook
+function useCountdown(targetDate: string) {
+ const [timeLeft, setTimeLeft] = useState(() => calculateTimeLeft(targetDate));
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setTimeLeft(calculateTimeLeft(targetDate));
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [targetDate]);
+
+ return timeLeft;
+}
+
+function calculateTimeLeft(targetDate: string) {
+ const now = new Date().getTime();
+ const target = new Date(targetDate).getTime();
+ const diff = target - now;
+
+ if (diff <= 0) {
+ return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
+ }
+
+ return {
+ days: Math.floor(diff / (1000 * 60 * 60 * 24)),
+ hours: Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
+ minutes: Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)),
+ seconds: Math.floor((diff % (1000 * 60)) / 1000),
+ expired: false,
+ };
+}
+
+// Compact Countdown Display Component (DD:HH:MM:SS format)
+function CompactCountdown({ targetDate, label }: { targetDate: string; label: string }) {
+ const { days, hours, minutes, seconds, expired } = useCountdown(targetDate);
+
+ if (expired) {
+ return (
+
+
{label}
+
+ 00
+ :
+ 00
+ :
+ 00
+
+
+ );
+ }
+
+ return (
+
+
{label}
+
+
+ {String(days).padStart(2, '0')}
+
+ :
+
+ {String(hours).padStart(2, '0')}
+
+ :
+
+ {String(minutes).padStart(2, '0')}
+
+ :
+
+ {String(seconds).padStart(2, '0')}
+
+
+
+ Days
+ Hours
+ Mins
+ Secs
+
+ {days > 0 && (
+
+ ({days} day{days > 1 ? 's' : ''} remaining)
+
+ )}
+
+ );
+}
+
+// Live status — replaces countdown for LIVE tournaments
+function LiveStatusDisplay({ agents }: { agents?: Tournament['agents'] }) {
+ const leadAgent = agents?.[0];
+ return (
+
+
Status
+
+ {leadAgent && (
+
+ Current Lead:
+ {leadAgent.emoji}
+ {leadAgent.name}
+
+ )}
+
+ );
+}
+
+// Upcoming status — shows countdown when active, or "Starting Soon" when expired
+function UpcomingStatusDisplay({ targetDate }: { targetDate: string }) {
+ const { expired } = useCountdown(targetDate);
+
+ if (expired) {
+ return (
+
+
Status
+
+
+ Starting Soon
+
+
Waiting for admin to start
+
+ );
+ }
+
+ return ;
+}
+
+// Upcoming action button — "Coming Soon" vs "Starting Soon"
+function UpcomingActionButton({ targetDate }: { targetDate: string }) {
+ const { expired } = useCountdown(targetDate);
+
+ if (expired) {
+ return (
+
+
+ Starting Soon
+
+ );
+ }
+
+ return (
+
+
+ Coming Soon
+
+ );
+}
+
+// Agent Preview Component
+const AgentPreview = memo(function AgentPreview({ agents, maxDisplay = 4 }: { agents?: Tournament['agents']; maxDisplay?: number }) {
+ if (!agents || agents.length === 0) return null;
+
+ const displayAgents = agents.slice(0, maxDisplay);
+ const remainingCount = agents.length - maxDisplay;
+
+ return (
+
+
Competing Agents
+
+ {displayAgents.map((agent, index) => (
+
+
+ {agent.emoji}
+
+ {/* Tooltip */}
+
+
+
{agent.name}
+ {(agent.strategy || agentDetails[agent.name]?.strategy) && (
+
+ {agent.strategy || agentDetails[agent.name]?.strategy}
+
+ )}
+ {(agent.personality || agentDetails[agent.name]?.personality) && (
+
+ {agent.personality || agentDetails[agent.name]?.personality}
+
+ )}
+
+
+
+
+ ))}
+ {remainingCount > 0 && (
+
+ +{remainingCount}
+
+ )}
+
+
+ );
+});
+
+function StatusBadge({ status }: { status: Tournament['status'] }) {
+ const statusConfig = {
+ LIVE: {
+ bg: 'bg-green-500/20',
+ text: 'text-green-400',
+ border: 'border-green-500/30',
+ dot: 'bg-green-400',
+ pulse: true,
+ },
+ UPCOMING: {
+ bg: 'bg-blue-500/20',
+ text: 'text-blue-400',
+ border: 'border-blue-500/30',
+ dot: 'bg-blue-400',
+ pulse: false,
+ },
+ ENDED: {
+ bg: 'bg-gray-500/20',
+ text: 'text-gray-400',
+ border: 'border-gray-500/30',
+ dot: 'bg-gray-400',
+ pulse: false,
+ },
+ };
+
+ const config = statusConfig[status];
+
+ return (
+
+
+
+ {status}
+
+
+ );
+}
+
+const TournamentCard = memo(function TournamentCard({ tournament, isActive }: { tournament: Tournament; isActive: boolean }) {
+ return (
+
+ {/* Card Background */}
+
+ {/* Header */}
+
+
+
+
+ ${tournament.prize_pool_usd.toLocaleString()}
+
+
+
+ {/* Tournament Name */}
+
{tournament.name}
+
+ {/* Description */}
+ {tournament.description && (
+
{tournament.description}
+ )}
+
+ {/* Prominent Countdown / Status */}
+
+ {tournament.status === 'LIVE' ? (
+
+ ) : tournament.status === 'UPCOMING' ? (
+
+ ) : (
+
+ Tournament Ended
+ Final results are in
+
+ )}
+
+
+ {/* Agent Preview + Stats */}
+
+
+
+
+
+ {tournament.participants}/{tournament.max_participants}
+
+
+
+
+ {/* Action Button */}
+
+ {tournament.status === 'LIVE' && (
+
+
+ {tournament.agents?.length ?? 0} Agents Trading
+
+ )}
+ {tournament.status === 'UPCOMING' && (
+
+ )}
+ {tournament.status === 'ENDED' && (
+
+ View Results
+
+ )}
+
+
+
+ );
+});
+
+export default function TournamentCarousel({ tournaments }: TournamentCarouselProps) {
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const containerRef = useRef(null);
+
+ const goToPrevious = () => {
+ setCurrentIndex((prev) => (prev === 0 ? tournaments.length - 1 : prev - 1));
+ };
+
+ const goToNext = () => {
+ setCurrentIndex((prev) => (prev === tournaments.length - 1 ? 0 : prev + 1));
+ };
+
+ // Sort tournaments: LIVE first, then UPCOMING, then ENDED
+ const sortedTournaments = [...tournaments].sort((a, b) => {
+ const statusOrder = { LIVE: 0, UPCOMING: 1, ENDED: 2 };
+ return statusOrder[a.status] - statusOrder[b.status];
+ });
+
+ return (
+
+ {/* Navigation Buttons */}
+
+
+
+
+
+
+
+
+ {/* Carousel Container */}
+
+
+ {/* Dots Indicator */}
+
+ {sortedTournaments.map((_, index) => (
+ setCurrentIndex(index)}
+ className={`w-2 h-2 rounded-full transition-all duration-300 ${
+ index === currentIndex
+ ? 'bg-[#FFD700] w-6'
+ : 'bg-white/30 hover:bg-white/50'
+ }`}
+ aria-label={`Go to tournament ${index + 1}`}
+ />
+ ))}
+
+
+ );
+}
diff --git a/frontend/app/components/Tournaments.tsx b/frontend/app/components/Tournaments.tsx
index 582931d..7cab194 100644
--- a/frontend/app/components/Tournaments.tsx
+++ b/frontend/app/components/Tournaments.tsx
@@ -1,8 +1,10 @@
'use client';
-import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
-import { spacing, typography, layout } from '../design-tokens';
+import { motion, useScroll, useTransform } from 'framer-motion';
+import { spacing, typography, layout, animations } from '../design-tokens';
+import TournamentCarousel from './TournamentCarousel';
+import { tournaments } from './ui/tournaments';
export default function Tournaments() {
const ref = useRef(null);
@@ -11,52 +13,63 @@ export default function Tournaments() {
offset: ['start end', 'end start'],
});
- // Parallax transforms
- const y = useTransform(scrollYProgress, [0, 1], [100, -100]);
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
+ // Filter out ended tournaments for carousel, or show all
+ const activeTournaments = tournaments.filter(t => t.status !== 'ENDED');
+ const displayTournaments = activeTournaments.length > 0 ? activeTournaments : tournaments;
+
return (
- {/* Background pattern that moves with parallax */}
-
-
-
-
{/* Content */}
-
-
+ {/* Heading */}
+
+
Tournaments
-
+
Watch AI agents compete in
live trading competitions
+
+
+ {/* Tournament Carousel - shows remaining tournaments */}
+ {displayTournaments.length > 1 && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
- {/* Coming Soon Badge */}
-
-
);
diff --git a/frontend/app/components/admin/agents/AgentCircleRoster.tsx b/frontend/app/components/admin/agents/AgentCircleRoster.tsx
new file mode 100644
index 0000000..57bb202
--- /dev/null
+++ b/frontend/app/components/admin/agents/AgentCircleRoster.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { Agent } from "@/types/admin";
+import { motion } from "framer-motion";
+import { useState } from "react";
+
+interface AgentCircleRosterProps {
+ agents: Agent[];
+}
+
+export default function AgentCircleRoster({ agents }: AgentCircleRosterProps) {
+ const [hoveredAgent, setHoveredAgent] = useState(null);
+
+ return (
+
+ {agents.map((agent, index) => (
+
setHoveredAgent(agent.id)}
+ onMouseLeave={() => setHoveredAgent(null)}
+ >
+ {/* Avatar Circle */}
+
+
+
+
+
+ {/* Hover Stats Tooltip */}
+ {hoveredAgent === agent.id && (
+
+ {agent.name}
+ {agent.personality}
+
+
+
+ Strategy:
+ {agent.strategy_type}
+
+
+ {agent.stats.total_tournaments !== undefined && (
+
+ Tournaments:
+ {agent.stats.total_tournaments}
+
+ )}
+
+ {agent.stats.wins !== undefined && (
+
+ Wins:
+ {agent.stats.wins}
+
+ )}
+
+ {agent.stats.win_rate !== undefined && (
+
+ Win Rate:
+
+ {(agent.stats.win_rate * 100).toFixed(1)}%
+
+
+ )}
+
+ {agent.stats.total_trades !== undefined && (
+
+ Total Trades:
+ {agent.stats.total_trades}
+
+ )}
+
+
+ )}
+
+
+ {/* Agent Name */}
+ {agent.name}
+ {agent.strategy_type}
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/components/admin/agents/CreateAgentModal.tsx b/frontend/app/components/admin/agents/CreateAgentModal.tsx
new file mode 100644
index 0000000..b931bcf
--- /dev/null
+++ b/frontend/app/components/admin/agents/CreateAgentModal.tsx
@@ -0,0 +1,208 @@
+"use client";
+
+import { useState } from "react";
+import { X, Bot } from "lucide-react";
+
+interface CreateAgentModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (agentData: AgentFormData) => void;
+}
+
+export interface AgentFormData {
+ name: string;
+ personality: string;
+ strategy_type: string;
+}
+
+export default function CreateAgentModal({
+ isOpen,
+ onClose,
+ onSubmit,
+}: CreateAgentModalProps) {
+ const [formData, setFormData] = useState({
+ name: "",
+ personality: "",
+ strategy_type: "",
+ });
+
+ const [errors, setErrors] = useState>>({});
+
+ const validateForm = (): boolean => {
+ const newErrors: Partial> = {};
+
+ if (!formData.name.trim()) {
+ newErrors.name = "Agent name is required";
+ }
+
+ if (!formData.personality.trim()) {
+ newErrors.personality = "Personality description is required";
+ }
+
+ if (!formData.strategy_type.trim()) {
+ newErrors.strategy_type = "Strategy type is required";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (validateForm()) {
+ onSubmit(formData);
+ handleClose();
+ }
+ };
+
+ const handleClose = () => {
+ setFormData({
+ name: "",
+ personality: "",
+ strategy_type: "",
+ });
+ setErrors({});
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
+
+
Create New Agent
+
+
+
+
+
+
+ {/* Form */}
+
+
+
+ >
+ );
+}
diff --git a/frontend/app/components/admin/common/HealthDot.tsx b/frontend/app/components/admin/common/HealthDot.tsx
new file mode 100644
index 0000000..5a56ef3
--- /dev/null
+++ b/frontend/app/components/admin/common/HealthDot.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import type { HealthStatus } from "@/types/admin";
+import { getHealthColor, getHealthLabel } from "@/lib/utils/admin/health";
+
+interface HealthDotProps {
+ status: HealthStatus;
+ size?: "sm" | "md" | "lg";
+ withLabel?: boolean;
+ className?: string;
+}
+
+const sizeClasses = {
+ sm: "w-2 h-2",
+ md: "w-3 h-3",
+ lg: "w-4 h-4",
+};
+
+export default function HealthDot({
+ status,
+ size = "md",
+ withLabel = false,
+ className = "",
+}: HealthDotProps) {
+ const color = getHealthColor(status);
+ const label = getHealthLabel(status);
+
+ return (
+
+
+ {withLabel && (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/components/admin/common/StatCard.tsx b/frontend/app/components/admin/common/StatCard.tsx
new file mode 100644
index 0000000..fc8e624
--- /dev/null
+++ b/frontend/app/components/admin/common/StatCard.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { ReactNode } from "react";
+import { motion } from "framer-motion";
+import { TrendingUp, TrendingDown } from "lucide-react";
+
+interface StatCardProps {
+ label: string;
+ value: string | number;
+ icon?: ReactNode;
+ trend?: "up" | "down" | "neutral";
+ trendValue?: string;
+ variant?: "default" | "electric" | "gold";
+ className?: string;
+}
+
+const variantStyles = {
+ default: "bg-white/5 border-white/10",
+ electric: "bg-blue-500/10 border-blue-500/20",
+ gold: "bg-yellow-500/10 border-yellow-500/20",
+};
+
+export default function StatCard({
+ label,
+ value,
+ icon,
+ trend,
+ trendValue,
+ variant = "default",
+ className = "",
+}: StatCardProps) {
+ return (
+
+
+
+
{label}
+
{value}
+
+ {trend && trendValue && (
+
+ {trend === "up" && (
+
+ )}
+ {trend === "down" && (
+
+ )}
+
+ {trendValue}
+
+
+ )}
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/components/admin/common/TournamentStatusBadge.tsx b/frontend/app/components/admin/common/TournamentStatusBadge.tsx
new file mode 100644
index 0000000..1bc708d
--- /dev/null
+++ b/frontend/app/components/admin/common/TournamentStatusBadge.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { TournamentStatus } from "@/types/admin";
+import { Clock, Zap, Trophy } from "lucide-react";
+
+interface TournamentStatusBadgeProps {
+ status: TournamentStatus;
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+const statusConfig = {
+ [TournamentStatus.UPCOMING]: {
+ label: "Upcoming",
+ bgColor: "bg-blue-500/20",
+ textColor: "text-blue-300",
+ borderColor: "border-blue-500/30",
+ icon: Clock,
+ pulse: false,
+ },
+ [TournamentStatus.LIVE]: {
+ label: "Live",
+ bgColor: "bg-white/10",
+ textColor: "text-white",
+ borderColor: "border-white/30",
+ icon: Zap,
+ pulse: true,
+ },
+ [TournamentStatus.COMPLETED]: {
+ label: "Completed",
+ bgColor: "bg-yellow-500/20",
+ textColor: "text-yellow-400",
+ borderColor: "border-yellow-500/30",
+ icon: Trophy,
+ pulse: false,
+ },
+};
+
+const sizeClasses = {
+ sm: "text-xs px-2 py-1",
+ md: "text-sm px-3 py-1.5",
+ lg: "text-base px-4 py-2",
+};
+
+export default function TournamentStatusBadge({
+ status,
+ size = "md",
+ className = "",
+}: TournamentStatusBadgeProps) {
+ const config = statusConfig[status];
+ const Icon = config.icon;
+
+ return (
+
+
+ {config.label}
+
+ );
+}
diff --git a/frontend/app/components/admin/common/TradeActionBadge.tsx b/frontend/app/components/admin/common/TradeActionBadge.tsx
new file mode 100644
index 0000000..4e1208e
--- /dev/null
+++ b/frontend/app/components/admin/common/TradeActionBadge.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { TradeAction } from "@/types/admin";
+import { TrendingUp, TrendingDown, Minus } from "lucide-react";
+
+interface TradeActionBadgeProps {
+ action: TradeAction;
+ size?: "sm" | "md";
+ className?: string;
+}
+
+const actionConfig = {
+ [TradeAction.BUY]: {
+ label: "BUY",
+ bgColor: "bg-green-500/20",
+ textColor: "text-green-400",
+ borderColor: "border-green-500/30",
+ icon: TrendingUp,
+ },
+ [TradeAction.SELL]: {
+ label: "SELL",
+ bgColor: "bg-red-500/20",
+ textColor: "text-red-400",
+ borderColor: "border-red-500/30",
+ icon: TrendingDown,
+ },
+ [TradeAction.HOLD]: {
+ label: "HOLD",
+ bgColor: "bg-gray-500/20",
+ textColor: "text-gray-400",
+ borderColor: "border-gray-500/30",
+ icon: Minus,
+ },
+};
+
+const sizeClasses = {
+ sm: "text-xs px-2 py-0.5",
+ md: "text-sm px-3 py-1",
+};
+
+export default function TradeActionBadge({
+ action,
+ size = "md",
+ className = "",
+}: TradeActionBadgeProps) {
+ const config = actionConfig[action];
+ const Icon = config.icon;
+
+ return (
+
+
+ {config.label}
+
+ );
+}
diff --git a/frontend/app/components/admin/layout/AdminSidebar.tsx b/frontend/app/components/admin/layout/AdminSidebar.tsx
new file mode 100644
index 0000000..c07a880
--- /dev/null
+++ b/frontend/app/components/admin/layout/AdminSidebar.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import Link from "next/link";
+import { Trophy, Users, ArrowLeft, Plus, Bot } from "lucide-react";
+import { useState, useEffect, useRef } from "react";
+
+const navItems = [
+ {
+ label: "Tournaments",
+ sectionId: "tournaments-section",
+ icon: Trophy,
+ },
+ {
+ label: "Agents",
+ sectionId: "agents-section",
+ icon: Users,
+ },
+];
+
+interface AdminSidebarProps {
+ onCreateTournament?: () => void;
+ onCreateAgent?: () => void;
+}
+
+export default function AdminSidebar({ onCreateTournament, onCreateAgent }: AdminSidebarProps) {
+ const [activeSection, setActiveSection] = useState("agents-section");
+ const [showCreateMenu, setShowCreateMenu] = useState(false);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ const sections = navItems.map(item => document.getElementById(item.sectionId));
+
+ for (const section of sections) {
+ if (section) {
+ const rect = section.getBoundingClientRect();
+ // Check if section is in viewport (top half of screen)
+ if (rect.top <= 200 && rect.bottom >= 200) {
+ setActiveSection(section.id);
+ break;
+ }
+ }
+ }
+ };
+
+ window.addEventListener("scroll", handleScroll);
+ return () => window.removeEventListener("scroll", handleScroll);
+ }, []);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setShowCreateMenu(false);
+ }
+ };
+
+ if (showCreateMenu) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [showCreateMenu]);
+
+ const scrollToSection = (sectionId: string) => {
+ const element = document.getElementById(sectionId);
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/app/components/admin/tournaments/CreateTournamentModal.tsx b/frontend/app/components/admin/tournaments/CreateTournamentModal.tsx
new file mode 100644
index 0000000..34c17c3
--- /dev/null
+++ b/frontend/app/components/admin/tournaments/CreateTournamentModal.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { X, Calendar, DollarSign, Trophy, Users } from "lucide-react";
+import DatePicker from "react-datepicker";
+import "react-datepicker/dist/react-datepicker.css";
+
+interface CreateTournamentModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmit: (tournamentData: TournamentFormData) => void;
+}
+
+export interface TournamentFormData {
+ name: string;
+ start_date: Date;
+ end_date: Date;
+ prize_pool: string;
+ max_agents: number;
+ description?: string;
+}
+
+// Helper to get default end date (7 days from now)
+function getDefaultEndDate(): Date {
+ const date = new Date();
+ date.setDate(date.getDate() + 7);
+ return date;
+}
+
+export default function CreateTournamentModal({
+ isOpen,
+ onClose,
+ onSubmit,
+}: CreateTournamentModalProps) {
+ // Use useMemo to compute initial values only once
+ const initialFormData = useMemo(() => ({
+ name: "",
+ start_date: new Date(),
+ end_date: getDefaultEndDate(),
+ prize_pool: "",
+ max_agents: 10,
+ description: "",
+ }), []);
+
+ const [formData, setFormData] = useState(initialFormData);
+
+ const [errors, setErrors] = useState>>({});
+
+ const validateForm = (): boolean => {
+ const newErrors: Partial> = {};
+
+ if (!formData.name.trim()) {
+ newErrors.name = "Tournament name is required";
+ }
+
+ if (!formData.prize_pool || parseFloat(formData.prize_pool) <= 0) {
+ newErrors.prize_pool = "Prize pool must be greater than 0";
+ }
+
+ if (formData.start_date >= formData.end_date) {
+ newErrors.end_date = "End date must be after start date";
+ }
+
+ if (formData.max_agents < 2) {
+ newErrors.max_agents = "Must allow at least 2 agents";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (validateForm()) {
+ onSubmit(formData);
+ handleClose();
+ }
+ };
+
+ const handleClose = () => {
+ setFormData({
+ name: "",
+ start_date: new Date(),
+ end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ prize_pool: "",
+ max_agents: 10,
+ description: "",
+ });
+ setErrors({});
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
+
+
Create New Tournament
+
+
+
+
+
+
+ {/* Form */}
+
+
+ {/* Tournament Name */}
+
+
setFormData({ ...formData, name: e.target.value })}
+ placeholder=" "
+ className={`w-full px-4 py-3.5 bg-white/5 border ${
+ errors.name ? "border-red-500" : "border-white/10"
+ } rounded-lg text-white placeholder-transparent focus:outline-none focus:border-blue-400 transition-colors peer`}
+ />
+
+ Tournament Name *
+
+ {errors.name && (
+
{errors.name}
+ )}
+
+
+ {/* Date Range */}
+
+ {/* Start Date */}
+
+ date && setFormData({ ...formData, start_date: date })}
+ selectsStart
+ startDate={formData.start_date}
+ endDate={formData.end_date}
+ minDate={new Date()}
+ dateFormat="MMM dd, yyyy"
+ className="w-full px-4 py-3.5 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-blue-400 transition-colors cursor-pointer"
+ calendarClassName="dark-calendar"
+ wrapperClassName="w-full"
+ />
+
+
+ Start Date
+
+
+
+ {/* End Date */}
+
+
date && setFormData({ ...formData, end_date: date })}
+ selectsEnd
+ startDate={formData.start_date}
+ endDate={formData.end_date}
+ minDate={formData.start_date}
+ dateFormat="MMM dd, yyyy"
+ className={`w-full px-4 py-3.5 bg-white/5 border ${
+ errors.end_date ? "border-red-500" : "border-white/10"
+ } rounded-lg text-white focus:outline-none focus:border-blue-400 transition-colors cursor-pointer`}
+ calendarClassName="dark-calendar"
+ wrapperClassName="w-full"
+ />
+
+
+ End Date
+
+ {errors.end_date && (
+ {errors.end_date}
+ )}
+
+
+
+ {/* Prize Pool & Max Agents */}
+
+ {/* Prize Pool */}
+
+
setFormData({ ...formData, prize_pool: e.target.value })}
+ placeholder=" "
+ min="0"
+ step="100"
+ className={`w-full px-4 py-3.5 bg-white/5 border ${
+ errors.prize_pool ? "border-red-500" : "border-white/10"
+ } rounded-lg text-white placeholder-transparent focus:outline-none focus:border-blue-400 transition-colors peer`}
+ />
+
+
+ Prize Pool (USD) *
+
+ {errors.prize_pool && (
+
{errors.prize_pool}
+ )}
+
+
+ {/* Max Agents */}
+
+
+ setFormData({ ...formData, max_agents: parseInt(e.target.value) })
+ }
+ placeholder=" "
+ min="2"
+ max="100"
+ className={`w-full px-4 py-3.5 bg-white/5 border ${
+ errors.max_agents ? "border-red-500" : "border-white/10"
+ } rounded-lg text-white placeholder-transparent focus:outline-none focus:border-blue-400 transition-colors peer`}
+ />
+
+
+ Agents
+
+ {errors.max_agents && (
+
{errors.max_agents}
+ )}
+
+
+
+ {/* Description */}
+
+ setFormData({ ...formData, description: e.target.value })}
+ placeholder=" "
+ rows={5}
+ className="w-full px-4 py-3.5 bg-white/5 border border-white/10 rounded-lg text-white placeholder-transparent focus:outline-none focus:border-blue-400 transition-colors resize-none peer"
+ />
+
+ Description (Optional)
+
+
+
+
+ {/* Footer */}
+
+
+ Cancel
+
+
+ Create Tournament
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/app/components/admin/tournaments/TournamentFilteredList.tsx b/frontend/app/components/admin/tournaments/TournamentFilteredList.tsx
new file mode 100644
index 0000000..d6f61c3
--- /dev/null
+++ b/frontend/app/components/admin/tournaments/TournamentFilteredList.tsx
@@ -0,0 +1,216 @@
+"use client";
+
+import { useState } from "react";
+import { TournamentWithMetrics, TournamentStatus } from "@/types/admin";
+import TournamentStatusBadge from "../common/TournamentStatusBadge";
+import { formatCurrency, formatDate } from "@/lib/utils/admin/formatters";
+import { motion, AnimatePresence } from "framer-motion";
+import { getAgentStatesByTournament } from "@/lib/mock/adminMockData";
+import { formatDistanceToNow } from "date-fns";
+
+interface TournamentFilteredListProps {
+ tournaments: TournamentWithMetrics[];
+ onSelectTournament: (tournament: TournamentWithMetrics) => void;
+}
+
+export default function TournamentFilteredList({
+ tournaments,
+ onSelectTournament,
+}: TournamentFilteredListProps) {
+ const [filter, setFilter] = useState("all");
+ const [hoveredAgent, setHoveredAgent] = useState(null);
+
+ const filteredTournaments =
+ filter === "all"
+ ? tournaments
+ : tournaments.filter((t) => t.status === filter);
+
+ return (
+
+ {/* Filter Buttons */}
+
+ setFilter("all")}
+ className={`px-4 py-2 rounded-lg font-medium transition-all ${
+ filter === "all"
+ ? "bg-white/10 text-white border border-white/20"
+ : "text-gray-400 hover:bg-white/5"
+ }`}
+ >
+ All
+
+ setFilter(TournamentStatus.UPCOMING)}
+ className={`px-4 py-2 rounded-lg font-medium transition-all ${
+ filter === TournamentStatus.UPCOMING
+ ? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
+ : "text-gray-400 hover:bg-white/5"
+ }`}
+ >
+ Upcoming
+
+ setFilter(TournamentStatus.LIVE)}
+ className={`px-4 py-2 rounded-lg font-medium transition-all ${
+ filter === TournamentStatus.LIVE
+ ? "bg-white/10 text-white border border-white/30"
+ : "text-gray-400 hover:bg-white/5"
+ }`}
+ >
+ Live
+
+ setFilter(TournamentStatus.COMPLETED)}
+ className={`px-4 py-2 rounded-lg font-medium transition-all ${
+ filter === TournamentStatus.COMPLETED
+ ? "bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"
+ : "text-gray-400 hover:bg-white/5"
+ }`}
+ >
+ Completed
+
+
+
+ {/* Tournament Cards */}
+
+ {filteredTournaments.map((tournament, index) => {
+ const agentStates = getAgentStatesByTournament(tournament.id);
+
+ return (
+
+ onSelectTournament(tournament)}
+ className="flex items-center justify-between mb-3"
+ >
+
+
+
+ {tournament.name}
+
+
+
+
+ {formatDate(tournament.start_date)} - {formatDate(tournament.end_date)}
+
+
+
+
+
+ {formatCurrency(tournament.prize_pool)}
+
+
{tournament.agent_count} agents
+
+
+
+ {/* Agent Roster */}
+ {agentStates.length > 0 && (
+
+
Agents:
+
+ {agentStates.slice(0, 8).map((agentState) => (
+
setHoveredAgent(`${tournament.id}-${agentState.agent_id}`)}
+ onMouseLeave={() => setHoveredAgent(null)}
+ >
+
+
+
+
+ {/* Hover Tooltip */}
+
+ {hoveredAgent === `${tournament.id}-${agentState.agent_id}` && (
+
+
+
+
+
+
+
+
+ {agentState.agent?.name}
+
+
+ Rank #{agentState.rank}
+
+
+
+
+
+
+ Portfolio Value:
+
+ {formatCurrency(agentState.portfolio_value_usd)}
+
+
+
+ Trades:
+ {agentState.trades_count}
+
+
+ Last Updated:
+
+ {formatDistanceToNow(new Date(agentState.updated_at), {
+ addSuffix: true
+ })}
+
+
+ {agentState.last_decision && (
+
+
Last Decision:
+
+ {agentState.last_decision}
+
+
+ )}
+
+
+
+ )}
+
+
+ ))}
+ {agentStates.length > 8 && (
+
+ +{agentStates.length - 8}
+
+ )}
+
+
+ )}
+
+ );
+ })}
+
+ {filteredTournaments.length === 0 && (
+
+
No tournaments found for this filter.
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/components/admin/tournaments/TournamentModal.tsx b/frontend/app/components/admin/tournaments/TournamentModal.tsx
new file mode 100644
index 0000000..d6ab180
--- /dev/null
+++ b/frontend/app/components/admin/tournaments/TournamentModal.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import { useState } from "react";
+import { Tournament, AgentState, Trade } from "@/types/admin";
+import { motion, AnimatePresence } from "framer-motion";
+import { X, Trophy, Calendar, DollarSign, Users } from "lucide-react";
+import { formatCurrency, formatDate } from "@/lib/utils/admin/formatters";
+import TournamentStatusBadge from "../common/TournamentStatusBadge";
+import HealthDot from "../common/HealthDot";
+import { formatDistanceToNow } from "date-fns";
+
+interface TournamentModalProps {
+ tournament: Tournament;
+ agentStates?: AgentState[];
+ trades?: Trade[];
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function TournamentModal({
+ tournament,
+ agentStates = [],
+ trades = [],
+ isOpen,
+ onClose,
+}: TournamentModalProps) {
+ const [hoveredAgent, setHoveredAgent] = useState(null);
+
+ if (!isOpen) return null;
+
+ // Sort agents by rank
+ const sortedAgents = [...agentStates].sort((a, b) => a.rank - b.rank);
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+ e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+
+
{tournament.name}
+
+
+
+
+
+ {formatDate(tournament.start_date)} - {formatDate(tournament.end_date)}
+
+
+
+ {formatCurrency(tournament.prize_pool)}
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {/* Stats Grid */}
+
+
+
+
+ Agents
+
+
{agentStates.length}
+
+
+
+
+
+ Total Trades
+
+
+ {agentStates.reduce((sum, a) => sum + a.trades_count, 0)}
+
+
+
+
+
+
+ Prize Pool
+
+
+ {formatCurrency(tournament.prize_pool)}
+
+
+
+
+ {/* Leaderboard */}
+
+
+
+ Leaderboard
+
+
+
+ {sortedAgents.map((state, index) => (
+
setHoveredAgent(state.agent_id)}
+ onMouseLeave={() => setHoveredAgent(null)}
+ >
+
2 ? 'bg-white/5 border-white/10 hover:bg-white/10' : ''}
+ `}
+ >
+
+
2 ? 'text-gray-500' : ''}
+ `}>
+ {index === 0 && '🥇'}
+ {index === 1 && '🥈'}
+ {index === 2 && '🥉'}
+ {index > 2 && `${state.rank}`}
+
+
+
+
+
+
+
{state.agent?.name || 'Unknown'}
+
{state.trades_count} trades
+
+
+
+
+
+
+ {formatCurrency(state.portfolio_value_usd)}
+
+
Portfolio Value
+
+
+
+
+
+ {/* Hover Tooltip */}
+
+ {hoveredAgent === state.agent_id && (
+
+
+
+
+
+
+
+
+ {state.agent?.name}
+
+
+ Rank #{state.rank}
+
+
+
+
+
+
+
Portfolio Value
+
+ {formatCurrency(state.portfolio_value_usd)}
+
+
+
+
Trades
+
{state.trades_count}
+
+
+
Strategy
+
{state.agent?.strategy_type}
+
+
+
Last Updated
+
+ {formatDistanceToNow(new Date(state.updated_at), {
+ addSuffix: true
+ })}
+
+
+ {state.last_decision && (
+
+
Last Decision
+
+ {state.last_decision}
+
+
+ )}
+
+
+
+ )}
+
+
+ ))}
+
+
+
+ {/* Recent Trades */}
+ {trades.length > 0 && (
+
+
Recent Trades
+
+ {trades.slice(0, 5).map((trade) => (
+
+
+ {trade.agent?.name}
+ {trade.action}
+ {trade.amount} {trade.asset}
+
+
{formatCurrency(trade.price)}
+
+ ))}
+
+
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/app/components/background/SectionBackground.tsx b/frontend/app/components/background/SectionBackground.tsx
new file mode 100644
index 0000000..e9fbbb7
--- /dev/null
+++ b/frontend/app/components/background/SectionBackground.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react';
+import { motion, useScroll, useTransform } from 'framer-motion';
+
+type Variant = 'base' | 'spotlight' | 'blips' | 'grid' | 'inverse' | 'grid-inverse';
+
+// Separate component so useScroll only runs when parallax is enabled
+function ParallaxLayer({ sectionRef }: { sectionRef: React.RefObject }) {
+ const { scrollYProgress } = useScroll({
+ target: sectionRef,
+ offset: ['start end', 'end start'],
+ });
+ const parallaxY = useTransform(scrollYProgress, [0, 1], [80, -80]);
+
+ return (
+
+ );
+}
+
+export default function SectionBackground({
+ id,
+ variant = 'base',
+ className = '',
+ baseClass,
+ gridClass,
+ parallax = false,
+ children,
+}: PropsWithChildren<{
+ id?: string;
+ variant?: Variant;
+ className?: string;
+ baseClass?: string;
+ gridClass?: string;
+ parallax?: boolean;
+}>) {
+ const [pos, setPos] = useState({ x: 50, y: 35 });
+ const sectionRef = useRef(null);
+
+ useEffect(() => {
+ if (variant !== 'spotlight') return;
+
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
+ if (mq.matches) return;
+
+ let raf = 0;
+ const onMove = (e: MouseEvent) => {
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(() => {
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ const x = (e.clientX / w) * 100;
+ const y = (e.clientY / h) * 100;
+ setPos({ x, y });
+ });
+ };
+
+ window.addEventListener('mousemove', onMove);
+ return () => {
+ cancelAnimationFrame(raf);
+ window.removeEventListener('mousemove', onMove);
+ };
+ }, [variant]);
+
+ const spotlightStyle = useMemo(() => {
+ if (variant !== 'spotlight') return undefined;
+ return {
+ background: `
+ radial-gradient(650px 420px at ${pos.x}% ${pos.y}%,
+ rgba(255,215,0,0.14), transparent 62%),
+ radial-gradient(800px 520px at ${pos.x * 0.7}% ${pos.y * 1.1}%,
+ rgba(34,211,238,0.10), transparent 66%)
+ `,
+ } as React.CSSProperties;
+ }, [pos, variant]);
+
+ const isInverse = variant === 'inverse' || variant === 'grid-inverse';
+ const isGridAccent = variant === 'grid' || variant === 'grid-inverse';
+
+ const baseLayerClass = baseClass || (isInverse ? 'bg-deep-inverse' : 'bg-deep');
+ const gridLayerClass = gridClass || (isGridAccent ? 'bg-grid-gold grid-flicker' : 'bg-grid');
+
+ return (
+
+
+
+
+
+ {variant === 'spotlight' && (
+
+ )}
+
+ {parallax && }
+
+ {variant === 'blips' && (
+ <>
+
+
+ >
+ )}
+
+ {children}
+
+ );
+}
+
+function SlowBlips() {
+ const blips = [
+ { s: 12, left: '12%', top: '35%', d: 10, delay: 0.2, o: 0.1 },
+ { s: 8, left: '70%', top: '18%', d: 12, delay: 0.8, o: 0.08 },
+ { s: 10, left: '82%', top: '60%', d: 14, delay: 0.1, o: 0.07 },
+ { s: 6, left: '35%', top: '72%', d: 16, delay: 1.2, o: 0.06 },
+ ];
+
+ return (
+
+ {blips.map((b, i) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/components/hero/AnimatedChartCard.tsx b/frontend/app/components/hero/AnimatedChartCard.tsx
new file mode 100644
index 0000000..ae5a67e
--- /dev/null
+++ b/frontend/app/components/hero/AnimatedChartCard.tsx
@@ -0,0 +1,309 @@
+'use client';
+
+// Floating glass card used in the hero background.
+import { motion, useAnimation } from 'framer-motion';
+import { useEffect, useState } from 'react';
+
+// Props from the hero section to position and highlight the card.
+interface AnimatedChartCardProps {
+ type: 'line' | 'bar'; // Chart type to render.
+ title: string; // Header label.
+ position: 'left' | 'right'; // Screen side placement.
+ delay: number; // Staggered entrance timing.
+ isVisible: boolean; // Triggers entrance animation.
+ isHighlighted: boolean; // External highlight state.
+ onHover: (isHovered: boolean) => void; // Notify parent for route highlight.
+ connectedRouteIndex: number; // Connected route index (used by parent).
+}
+
+export default function AnimatedChartCard({
+ type,
+ title,
+ position,
+ delay,
+ isVisible,
+ isHighlighted,
+ onHover,
+}: AnimatedChartCardProps) {
+ const controls = useAnimation();
+ const [isHovered, setIsHovered] = useState(false);
+
+ // Start entrance when the hero section becomes visible.
+ useEffect(() => {
+ if (isVisible) {
+ controls.start('visible');
+ }
+ }, [isVisible, controls]);
+
+ // Slide/fade in from the side with a slight 3D turn.
+ const cardVariants = {
+ hidden: {
+ x: position === 'left' ? -100 : 100,
+ opacity: 0,
+ rotateY: position === 'left' ? -15 : 15,
+ },
+ visible: {
+ x: 0,
+ opacity: 1,
+ rotateY: 0,
+ transition: {
+ delay: 1.0 + delay * 0.15,
+ duration: 0.6,
+ ease: [0.4, 0, 0.2, 1] as const,
+ },
+ },
+ };
+
+ const handleMouseEnter = () => {
+ // Highlight card and connected route.
+ setIsHovered(true);
+ onHover(true);
+ };
+
+ const handleMouseLeave = () => {
+ // Remove highlight on exit.
+ setIsHovered(false);
+ onHover(false);
+ };
+
+ return (
+
+
+ {/* Header bar with live indicator dot. */}
+
+
+ {/* Chart body: line or bars. */}
+
+ {type === 'line' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Shine sweep to emphasize hover. */}
+
+
+
+ );
+}
+
+// SVG line chart used inside the card.
+function LineChart({ isActive }: { isActive: boolean }) {
+ const points = [
+ { x: 10, y: 60 },
+ { x: 20, y: 45 },
+ { x: 40, y: 55 },
+ { x: 60, y: 30 },
+ { x: 80, y: 40 },
+ { x: 100, y: 20 },
+ { x: 120, y: 35 },
+ { x: 140, y: 15 },
+ ];
+
+ // Path for the line and its filled area.
+ const pathD = points
+ .map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`))
+ .join(' ');
+ const areaD = `${pathD} L 140 70 L 0 70 Z`;
+
+ return (
+
+
+ {/* Gold gradients match brand accent. */}
+
+
+
+
+
+
+
+
+
+
+ {/* Grid adds depth and scale. */}
+ {[0, 25, 50].map((y) => (
+
+ ))}
+
+ {/* Area glow under the line. */}
+
+
+ {/* Draw the line on entrance. */}
+
+
+ {/* Moving dot signals "live" activity. */}
+ {isActive && (
+
+
+
+ )}
+
+ {/* Points add detail along the line. */}
+ {points.map((p, i) => (
+
+ ))}
+
+ );
+}
+
+// SVG bar chart used inside the card.
+function BarChart({ isActive }: { isActive: boolean }) {
+ const bars = [
+ { height: 70, color: '#FFD700' },
+ { height: 60, color: '#FFD700' },
+ { height: 35, color: '#FFD700' },
+ { height: 75, color: '#FFD700' },
+ { height: 50, color: '#FFD700' },
+ { height: 65, color: '#FFD700' },
+ ];
+
+ return (
+
+
+ {/* Vertical gradient for bar depth. */}
+
+
+
+
+
+
+ {/* Grid adds depth and scale. */}
+ {[0, 25, 50].map((y) => (
+
+ ))}
+
+ {/* Bars grow in and pulse when active. */}
+ {bars.map((bar, i) => (
+
+
+
+ {/* Values show only when active. */}
+ {isActive && (
+
+ {bar.height}
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/components/hero/Hero.tsx b/frontend/app/components/hero/Hero.tsx
new file mode 100644
index 0000000..75e245f
--- /dev/null
+++ b/frontend/app/components/hero/Hero.tsx
@@ -0,0 +1,321 @@
+'use client';
+
+import Link from 'next/link';
+import { motion, useScroll, useTransform } from 'framer-motion';
+import { useRef, useState, memo } from 'react';
+import PlayRoutes from './PlayRoutes';
+import NetworkNode, {
+ AgentIcon,
+ ChartIcon,
+ WalletIcon,
+ TrophyIcon,
+ BetIcon,
+} from './NetworkNode';
+import AnimatedChartCard from './AnimatedChartCard';
+
+// Ambient particles for the hero background (reduced for performance).
+const PARTICLES = [
+ { id: 0, left: 12, top: 23, duration: 3.5, delay: 0.2 },
+ { id: 1, left: 45, top: 67, duration: 4.1, delay: 0.8 },
+ { id: 2, left: 78, top: 34, duration: 3.2, delay: 1.5 },
+ { id: 3, left: 23, top: 89, duration: 4.5, delay: 0.4 },
+ { id: 4, left: 56, top: 12, duration: 3.8, delay: 1.1 },
+ { id: 5, left: 89, top: 56, duration: 4.3, delay: 0.6 },
+ { id: 6, left: 34, top: 45, duration: 3.1, delay: 1.8 },
+ { id: 7, left: 67, top: 78, duration: 4.7, delay: 0.3 },
+];
+
+// Node positions for the interactive network layer.
+const nodes = [
+ {
+ id: 0,
+ icon: ,
+ label: 'AI Agents',
+ tooltip: 'Autonomous trading agents',
+ position: { x: 22, y: 35 },
+ },
+ {
+ id: 1,
+ icon: ,
+ label: 'Analytics',
+ tooltip: 'Real-time performance metrics',
+ position: { x: 73, y: 25 },
+ },
+ {
+ id: 2,
+ icon: ,
+ label: 'Wallet',
+ tooltip: 'Secure Web3 integration',
+ position: { x: 13, y: 50 },
+ },
+ {
+ id: 3,
+ icon: ,
+ label: 'Tournaments',
+ tooltip: 'Compete for prizes',
+ position: { x: 82, y: 46 },
+ },
+ {
+ id: 4,
+ icon: ,
+ label: 'Betting',
+ tooltip: 'Place strategic bets',
+ position: { x: 20, y: 80 },
+ },
+];
+
+// Memoized particles layer — prevents re-render when hover state changes
+const ParticlesLayer = memo(function ParticlesLayer() {
+ return (
+ <>
+ {PARTICLES.map((particle) => (
+
+ ))}
+ >
+ );
+});
+
+export default function Hero() {
+ const ref = useRef(null);
+ const isVisible = true; // Always visible on mount
+ const [hoveredNode, setHoveredNode] = useState(null);
+ const [hoveredCard, setHoveredCard] = useState<'left' | 'right' | null>(null);
+
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ['start start', 'end start'],
+ });
+
+ // Parallax transforms for background layers.
+ const y1 = useTransform(scrollYProgress, [0, 1], [0, 200]);
+ const y2 = useTransform(scrollYProgress, [0, 1], [0, 100]);
+ const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 0.8, 0]);
+
+
+
+ // Map hovered nodes/cards to route highlights.
+ const getHighlightedRoute = () => {
+ if (hoveredNode !== null) {
+ // Map nodes to routes
+ const nodeToRoute: { [key: number]: number } = {
+ 0: 0, // Agent -> Route 1
+ 1: 1, // Analytics -> Route 2
+ 2: 2, // Wallet -> Route 3
+ 3: 3, // Tournaments -> Route 4
+ 4: 4, // Betting -> Route 5
+ };
+ return nodeToRoute[hoveredNode] ?? null;
+ }
+ if (hoveredCard === 'left') return 2;
+ if (hoveredCard === 'right') return 3;
+ return null;
+ };
+
+ return (
+
+ {/* ========== LAYER 1: Background ========== */}
+
+
+
+
+ {/* ========== LAYER 2: Play Routes (Football-style SVG) ========== */}
+
+
+ {/* ========== LAYER 3: Graph Cards ========== */}
+
+ setHoveredCard(isHovered ? 'left' : null)}
+ connectedRouteIndex={2}
+ />
+ setHoveredCard(isHovered ? 'right' : null)}
+ connectedRouteIndex={3}
+ />
+
+
+ {/* ========== LAYER 4: Network Nodes ========== */}
+
+ {nodes.map((node, index) => (
+
+ ))}
+
+
+ {/* ========== LAYER 5: Headline Content ========== */}
+
+ {/* Main Headline */}
+
+
+
+ Trusting the Algorithm,
+
+
+ Not my Intuition
+
+
+
+
+ {/* Subheadline */}
+
+ Watch autonomous AI agents compete in real-time trading tournaments.
+ Place bets, track performance, and win big.
+
+
+ {/* CTA Buttons */}
+
+
+ {/* Shine effect */}
+
+ Watch Tournament
+
+
+
+
+
+
+
+
+ {/* Floating stats */}
+
+
+
+ $50K+
+ Prize Pools
+
+
+
+
+
+ 24+
+ AI Agents
+
+
+
+
+
+ 1,000+
+ Trades Daily
+
+
+
+
+
+ {/* Scroll indicator */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/components/hero/NetworkNode.tsx b/frontend/app/components/hero/NetworkNode.tsx
new file mode 100644
index 0000000..041e89f
--- /dev/null
+++ b/frontend/app/components/hero/NetworkNode.tsx
@@ -0,0 +1,242 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { useState } from 'react';
+
+interface NetworkNodeProps {
+ id: number;//unique node id for hover/highlight logic
+ icon: React.ReactNode;//icon graphic rendered inside bubble
+ label: string;//text under bubble
+ tooltip: string; //tooltip text shown on hover
+ position: { x: number; y: number }; //where to place on hero (%based)
+ delay: number; //stagger animation timing across nodes
+ isVisible: boolean; //trigger entrance (scale 0 -> 1)
+ onHover: (id: number | null) => void;//tells parents which node is hovered
+ isHighlighted: boolean;//active or not
+}
+
+export default function NetworkNode({
+ id,
+ icon,
+ label,
+ tooltip,
+ position,
+ delay,
+ isVisible,
+ onHover,
+ isHighlighted,
+}: NetworkNodeProps) {
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ const handleMouseEnter = () => {
+ // Highlight node and show tooltip on hover.
+ setShowTooltip(true);
+ onHover(id);
+ };
+
+ const handleMouseLeave = () => {
+ // Reset highlight on leave.
+ setShowTooltip(false);
+ onHover(null);
+ };
+
+ const nodeVariants = {
+ hidden: {
+ scale: 0,
+ opacity: 0,
+ },
+ visible: {
+ scale: 1,
+ opacity: 1,
+ transition: {
+ delay: 1.3 + delay * 0.1,
+ duration: 0.4,
+ type: 'spring' as const,
+ stiffness: 200,
+ damping: 15,
+ },
+ },
+ };
+
+ const pulseVariants = {
+ pulse: {
+ scale: [1, 1.2, 1],
+ opacity: [0.5, 0, 0.5],
+ transition: {
+ duration: 2,
+ repeat: Infinity,
+ ease: 'easeInOut' as const,
+ },
+ },
+ };
+
+ return (
+ //outer wrapping absolute positioning in hero
+
+ {/* Pulsing rings create ambient activity. */}
+ {/* Outer pulse rings */}
+
+
+
+ {/* Main node bubble with icon. */}
+ {/* Main node container */}
+
+ {/* Icon */}
+
+ {icon}
+
+
+ {/* Active dot reinforces "live" status. */}
+ {/* Active indicator dot */}
+
+
+
+ {/* Label anchors the node's meaning. */}
+ {/* Label */}
+
+ {label}
+
+
+ {/* Tooltip explains the node on hover. */}
+ {/* Tooltip */}
+
+ {tooltip}
+ {/* Tooltip arrow */}
+
+
+
+ );
+}
+
+// Icon components for nodes
+export function AgentIcon() {
+ return (
+
+
+
+
+
+ );
+}
+
+export function ChartIcon() {
+ return (
+
+
+
+
+ );
+}
+
+export function WalletIcon() {
+ return (
+
+
+
+
+
+ );
+}
+
+export function TrophyIcon() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export function BetIcon() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/components/hero/PlayRoutes.tsx b/frontend/app/components/hero/PlayRoutes.tsx
new file mode 100644
index 0000000..9ded264
--- /dev/null
+++ b/frontend/app/components/hero/PlayRoutes.tsx
@@ -0,0 +1,307 @@
+'use client';
+
+import { motion, useAnimation } from 'framer-motion';
+import { useEffect, forwardRef, useImperativeHandle } from 'react';
+
+export interface PlayRoutesRef {
+ highlightRoute: (routeIndex: number | null) => void;
+}
+
+interface PlayRoutesProps {
+ isVisible: boolean;
+ highlightedRoute: number | null;
+}
+
+const PlayRoutes = forwardRef(
+ ({ isVisible, highlightedRoute }, ref) => {
+ const controls = useAnimation();
+
+ // Define routes - football play style paths
+ // Routes connect from a central "quarterback" position to various "receiver" positions
+ const routes = [
+ {
+ // Route 1: Slant route to top-left (connects to node 1)
+ path: 'M 400 400 Q 300 350 200 250',
+ endPoint: { x: 200, y: 250 },
+ color: '#FFD700',
+ },
+ {
+ // Route 2: Deep route to top-right (connects to node 2)
+ path: 'M 400 400 Q 500 300 600 180',
+ endPoint: { x: 600, y: 180 },
+ color: '#FFD700',
+ },
+ {
+ // Route 3: Out route to left (connects to card 1)
+ path: 'M 400 400 Q 250 400 120 320',
+ endPoint: { x: 120, y: 320 },
+ color: '#FFD700',
+ },
+ {
+ // Route 4: Curl route to right (connects to card 2)
+ path: 'M 400 400 Q 550 380 680 300',
+ endPoint: { x: 680, y: 300 },
+ color: '#FFD700',
+ },
+ {
+ // Route 5: Screen route to bottom-left (connects to node 3)
+ path: 'M 400 400 Q 300 450 180 480',
+ endPoint: { x: 180, y: 480 },
+ color: '#FFD700',
+ },
+ ];
+
+ useImperativeHandle(ref, () => ({
+ highlightRoute: () => {
+ // This can be used for external control
+ },
+ }));
+
+ useEffect(() => {
+ // Draw routes when the hero becomes visible.
+ if (isVisible) {
+ // Start drawing routes at 0.3s
+ controls.start('visible');
+ }
+ }, [isVisible, controls]);
+
+ const pathVariants = {
+ hidden: {
+ pathLength: 0,
+ opacity: 0,
+ },
+ visible: (i: number) => ({
+ pathLength: 1,
+ opacity: 1,
+ transition: {
+ pathLength: {
+ delay: 0.3 + i * 0.1,
+ duration: 0.5,
+ ease: 'easeInOut' as const,
+ },
+ opacity: {
+ delay: 0.3 + i * 0.1,
+ duration: 0.1,
+ },
+ },
+ }),
+ };
+
+ const ballVariants = {
+ hidden: {
+ opacity: 0,
+ scale: 0,
+ },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ transition: {
+ delay: 0.8,
+ duration: 0.3,
+ },
+ },
+ };
+
+ return (
+
+
+ {/* Glow for highlighted routes. */}
+ {/* Glow filter for highlighted routes */}
+
+
+
+
+
+
+
+
+ {/* Gold gradient for route strokes. */}
+ {/* Gradient for routes */}
+
+
+
+
+
+
+ {/* Ball shading for depth. */}
+ {/* Ball gradient */}
+
+
+
+
+
+
+
+ {/* Animated play routes in the background. */}
+ {/* Draw routes */}
+ {routes.map((route, index) => (
+
+ {/* Background dashed route (always visible after draw) */}
+
+
+ {/* Bright overlay when hovered/linked. */}
+ {/* Highlighted route overlay */}
+
+
+ {/* Moving dot to imply motion along the route. */}
+ {/* Animated moving dot along route (shows on highlight) */}
+ {highlightedRoute === index && (
+
+
+
+ )}
+
+ ))}
+
+ {/* Center "football" marker. */}
+ {/* Center ball (quarterback position) */}
+
+ {/* Ball shadow */}
+
+
+ {/* Main ball */}
+
+
+ {/* Ball laces */}
+
+
+
+
+
+
+
+ {/* Expanding rings reinforce the center focus. */}
+ {/* Pulse rings from ball (shows after ball appears) */}
+ {isVisible && (
+ <>
+ {[0, 1, 2].map((i) => (
+
+ ))}
+ >
+ )}
+
+ );
+ }
+);
+
+PlayRoutes.displayName = 'PlayRoutes';
+
+export default PlayRoutes;
diff --git a/frontend/app/components/hero/index.ts b/frontend/app/components/hero/index.ts
new file mode 100644
index 0000000..0a54e95
--- /dev/null
+++ b/frontend/app/components/hero/index.ts
@@ -0,0 +1,6 @@
+// Hero namespace re-exports for cleaner imports.
+export { default as Hero } from './Hero';
+export { default as PlayRoutes } from './PlayRoutes';
+export { default as NetworkNode } from './NetworkNode';
+export { default as AnimatedChartCard } from './AnimatedChartCard';
+export * from './NetworkNode';
diff --git a/frontend/app/components/nav/DashboardNavbar.tsx b/frontend/app/components/nav/DashboardNavbar.tsx
new file mode 100644
index 0000000..88bf21c
--- /dev/null
+++ b/frontend/app/components/nav/DashboardNavbar.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import Link from 'next/link';
+import { useState, useEffect } from 'react';
+import { Menu, X } from 'lucide-react';
+import { AnimatePresence, motion } from 'framer-motion';
+import NavbarAccount from './NavbarAccount';
+import { scrollToSection } from './scrollToSection';
+import { useActiveSection } from './useActiveSection';
+import MarketBlips from './MarketBlips';
+
+const SECTIONS = [
+ { name: 'Tournaments', id: 'tournaments' },
+ { name: 'Performance', id: 'performance' },
+ { name: 'Recent Trades', id: 'recent-trades' },
+];
+
+export default function DashboardNavbar() {
+ const [open, setOpen] = useState(false);
+ const [scrolled, setScrolled] = useState(false);
+
+ // Get section IDs for scrollspy
+ const sectionIds = SECTIONS.map((s) => s.id);
+ const activeSection = useActiveSection(sectionIds);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ setScrolled(window.scrollY > 50);
+ };
+
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const handleSectionClick = (id: string) => {
+ scrollToSection(id);
+ setOpen(false);
+ };
+
+ return (
+
+
+
+ {/* Logo - stays on /home */}
+
+
+
+
+ {/* Desktop section links */}
+
+ {SECTIONS.map((s) => (
+ handleSectionClick(s.id)}
+ className={`relative text-sm font-medium transition ${
+ activeSection === s.id
+ ? 'text-[#FFD700] font-semibold'
+ : 'text-gray-300 hover:text-[#FFD700]'
+ }`}
+ >
+ {s.name}
+ {/* Active indicator underline */}
+ {activeSection === s.id && (
+
+ )}
+
+ ))}
+
+
+ {/* Right: Connect / Profile */}
+
+
+
+
+ {/* Mobile toggle */}
+
setOpen((v) => !v)}
+ className="md:hidden p-2 text-gray-300 hover:text-white"
+ aria-label="Toggle menu"
+ >
+ {open ? : }
+
+
+
+ {/* Mobile menu */}
+
+ {open && (
+
+ {SECTIONS.map((s, index) => (
+ handleSectionClick(s.id)}
+ className={`block w-full text-left px-4 py-2 rounded-lg transition ${
+ activeSection === s.id
+ ? 'text-[#FFD700] font-semibold bg-white/5'
+ : 'text-gray-300 hover:text-[#FFD700] hover:bg-white/5'
+ }`}
+ >
+ {s.name}
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/components/nav/InfoNavbar.tsx b/frontend/app/components/nav/InfoNavbar.tsx
new file mode 100644
index 0000000..e3e4300
--- /dev/null
+++ b/frontend/app/components/nav/InfoNavbar.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import Link from 'next/link';
+import NavbarAccount from './NavbarAccount';
+import MarketBlips from './MarketBlips';
+
+export default function InfoNavbar({ title }: { title: string }) {
+ return (
+
+
+ {/* Left: Logo → landing page */}
+
+
+
+
+
+
+ {/* Center: Page title */}
+
+
+ {title}
+
+
+
+ {/* Right: Connect wallet / user profile */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/components/nav/LandingNavbar.tsx b/frontend/app/components/nav/LandingNavbar.tsx
new file mode 100644
index 0000000..0cf588b
--- /dev/null
+++ b/frontend/app/components/nav/LandingNavbar.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { Menu, X } from 'lucide-react';
+import { useAccount } from 'wagmi';
+import { motion, AnimatePresence } from 'framer-motion';
+import { ConnectWallet } from '@/src/components/ConnectWallet';
+import { useActiveSection } from './useActiveSection';
+import MarketBlips from './MarketBlips';
+
+const navLinks = [
+ { name: 'Home', href: 'home' },
+ { name: 'How It Works', href: 'how-it-works' },
+ { name: 'Agents', href: 'agents' },
+ { name: 'Tournaments', href: 'tournaments' },
+];
+
+export default function LandingNavbar() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [scrolled, setScrolled] = useState(false);
+ const { isConnected } = useAccount();
+
+ // Get section IDs for scrollspy
+ const sectionIds = navLinks.map((link) => link.href);
+ const activeSection = useActiveSection(sectionIds);
+
+ useEffect(() => {
+ const handleScroll = () => {
+ setScrolled(window.scrollY > 50);
+ };
+
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const handleNavClick = (
+ e: React.MouseEvent,
+ href: string
+ ) => {
+ e.preventDefault();
+ setIsOpen(false);
+
+ const element = document.getElementById(href);
+ if (element) {
+ element.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ }
+ };
+
+ return (
+
+
+ {/* Left: Logo + Visual Accent */}
+
+
+
+
+
+
+ {/* Center: Desktop Links */}
+
+
+ {/* Right: Wallet + Mobile Menu */}
+
+ {isConnected && (
+
+ Enter Dashboard
+
+ )}
+
+
+
+
setIsOpen(!isOpen)}
+ aria-label="Toggle menu"
+ className="md:hidden"
+ >
+ {isOpen ? : }
+
+
+
+
+ {/* Mobile Menu */}
+
+ {isOpen && (
+
+ {navLinks.map((link, index) => (
+
+ handleNavClick(e, link.href)}
+ className={`block py-2 hover:text-[#FFD700] transition cursor-pointer ${
+ activeSection === link.href
+ ? 'text-[#FFD700] font-semibold'
+ : 'text-white'
+ }`}
+ >
+ {link.name}
+
+
+ ))}
+
+ {isConnected && (
+
+
+ Enter Dashboard
+
+
+ )}
+
+ {/* Real ConnectWallet on mobile */}
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/components/nav/MarketBlips.tsx b/frontend/app/components/nav/MarketBlips.tsx
new file mode 100644
index 0000000..63ab5e8
--- /dev/null
+++ b/frontend/app/components/nav/MarketBlips.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { motion, useReducedMotion } from 'framer-motion';
+
+type Blip = {
+ size: number;
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ duration: number;
+ delay: number;
+ opacity: number;
+ radius: number;
+};
+
+const BLIPS: Blip[] = [
+ { size: 10, x0: 6, y0: 34, x1: 64, y1: 6, duration: 6.5, delay: 0.0, opacity: 0.18, radius: 4 },
+ { size: 8, x0: 20, y0: 8, x1: 72, y1: 30, duration: 7.5, delay: 0.6, opacity: 0.14, radius: 4 },
+ { size: 12, x0: 40, y0: 28, x1: 8, y1: 10, duration: 8.5, delay: 1.1, opacity: 0.12, radius: 5 },
+ { size: 6, x0: 58, y0: 14, x1: 18, y1: 34, duration: 9.0, delay: 0.3, opacity: 0.10, radius: 3 },
+];
+
+export default function MarketBlips({
+ className = '',
+ width = 120,
+ height = 44,
+ label = 'Agonus',
+}: {
+ className?: string;
+ width?: number;
+ height?: number;
+ label?: string;
+}) {
+ const reduce = useReducedMotion();
+
+ return (
+
+
+
+ {BLIPS.map((b, i) => (
+
+ ))}
+
+
+
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/frontend/app/components/nav/NavbarAccount.tsx b/frontend/app/components/nav/NavbarAccount.tsx
new file mode 100644
index 0000000..e9431b8
--- /dev/null
+++ b/frontend/app/components/nav/NavbarAccount.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { useAccount } from 'wagmi';
+import { WalletOptionsMenu } from '@/src/components/wallet-options';
+import UserDropdown from './UserDropdown';
+
+export default function NavbarAccount() {
+ const { isConnected } = useAccount();
+
+ // disconnected: show existing connect wallet UI
+ if (!isConnected) {
+ return ;
+ }
+
+ // connected: show profile icon + dropdown
+ return ;
+}
diff --git a/frontend/app/components/nav/UserDropdown.tsx b/frontend/app/components/nav/UserDropdown.tsx
new file mode 100644
index 0000000..d9850cb
--- /dev/null
+++ b/frontend/app/components/nav/UserDropdown.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAccount } from 'wagmi';
+import { AnimatePresence, motion } from 'framer-motion';
+import { AtSign, Edit, LogOut, Mail, User, Wallet, Loader2, CheckCircle } from 'lucide-react';
+import { loadUserProfile, saveUserProfile, type UserProfile } from './userProfileStorage';
+import { useWalletAuth } from '@/src/hooks/useWalletAuth';
+
+function truncate(addr: string) {
+ return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
+}
+
+export default function UserDropdown() {
+ const { address, isConnected } = useAccount();
+ const { isAuthenticated, isSigningIn, signInError, signIn, fullDisconnect } = useWalletAuth();
+
+ const [open, setOpen] = useState(false);
+ const [editing, setEditing] = useState(false);
+
+ const [profile, setProfile] = useState({
+ name: '',
+ email: '',
+ twitter: '',
+ });
+
+ const ref = useRef(null);
+
+ // close on outside click
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (!ref.current) return;
+ if (!ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ setEditing(false);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, []);
+
+ // load profile when wallet changes
+ useEffect(() => {
+ if (!address) return;
+ const loaded = loadUserProfile(address);
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setProfile(loaded);
+ }, [address]);
+
+ const addrLabel = useMemo(() => (address ? truncate(address) : ''), [address]);
+
+ const onSave = () => {
+ if (!address) return;
+ saveUserProfile(address, profile);
+ setEditing(false);
+ };
+
+ if (!isConnected || !address) return null;
+
+ return (
+
+ {/* Avatar button */}
+
setOpen((v) => !v)}
+ className="flex items-center gap-2 px-2.5 py-1.5 rounded-full border border-white/10 bg-white/5 hover:bg-white/10 transition"
+ aria-label="Open user menu"
+ >
+
+
+
+
+
+ {addrLabel}
+
+
+
+
+
+
+ {open && (
+
+ {/* Header */}
+
+
Connected Wallet
+
{addrLabel}
+
+
+ {/* Sign In Section - shown when wallet connected but not authenticated */}
+ {!isAuthenticated && (
+
+
+
+
+
+
+
Sign in to Agonus
+
+ Sign a message to verify wallet ownership and access betting features.
+
+
+ {signInError && (
+
{signInError}
+ )}
+
+
+ {isSigningIn ? (
+ <>
+
+ Signing...
+ >
+ ) : (
+ 'Sign Message'
+ )}
+
+
+
+
+ )}
+
+ {/* Auth Status - shown when authenticated */}
+ {isAuthenticated && (
+
+ )}
+
+ {/* Profile */}
+
+
+
Profile
+
+
setEditing((v) => !v)}
+ className="p-2 rounded-lg hover:bg-white/10 transition"
+ aria-label="Toggle edit profile"
+ >
+
+
+
+
+ {editing ? (
+
+ ) : (
+
+
+
+ Name
+
+ {profile.name || 'Not set'}
+
+
+
+
+ Email
+
+ {profile.email || 'Not set'}
+
+
+
+
+ Twitter
+
+
{profile.twitter || 'Not set'}
+
+
+ )}
+
+
+ {/* Actions */}
+
+ {
+ fullDisconnect();
+ setOpen(false);
+ setEditing(false);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-xl text-red-300 hover:bg-red-500/10 transition"
+ >
+
+ Disconnect
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/components/nav/scrollToSection.ts b/frontend/app/components/nav/scrollToSection.ts
new file mode 100644
index 0000000..51611c0
--- /dev/null
+++ b/frontend/app/components/nav/scrollToSection.ts
@@ -0,0 +1,7 @@
+export function scrollToSection(id: string) {
+ const el = document.getElementById(id);
+ if (!el) return;
+
+ // Parallax pages often have transforms/scroll effects; this is the most reliable:
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
diff --git a/frontend/app/components/nav/useActiveSection.ts b/frontend/app/components/nav/useActiveSection.ts
new file mode 100644
index 0000000..a351aa4
--- /dev/null
+++ b/frontend/app/components/nav/useActiveSection.ts
@@ -0,0 +1,70 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+
+/**
+ * Scrollspy hook that tracks which section is currently in view
+ * Uses scroll position for reliability with parallax effects
+ */
+export function useActiveSection(sectionIds: string[], offset: number = 100) {
+ const [activeSection, setActiveSection] = useState('');
+ const ticking = useRef(false);
+
+ useEffect(() => {
+ // Set initial section
+ if (sectionIds.length > 0 && !activeSection) {
+ const hash = window.location.hash.replace('#', '');
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setActiveSection(hash && sectionIds.includes(hash) ? hash : sectionIds[0]);
+ }
+
+ const handleScroll = () => {
+ if (ticking.current) return;
+
+ ticking.current = true;
+
+ requestAnimationFrame(() => {
+ const scrollY = window.scrollY + offset;
+ const windowHeight = window.innerHeight;
+ const documentHeight = document.documentElement.scrollHeight;
+
+ // Check if we're at the bottom of the page
+ if (scrollY + windowHeight >= documentHeight - 50) {
+ // At bottom, activate last section
+ const lastSection = sectionIds[sectionIds.length - 1];
+ // Using functional update pattern for scroll handler
+ setActiveSection((prev) => (prev === lastSection ? prev : lastSection));
+ ticking.current = false;
+ return;
+ }
+
+ // Find the section that's currently in view
+ let currentSection = sectionIds[0];
+
+ for (const id of sectionIds) {
+ const element = document.getElementById(id);
+ if (!element) continue;
+
+ const rect = element.getBoundingClientRect();
+ const elementTop = rect.top + window.scrollY;
+
+ // If the section's top is above our scroll position (with offset), it's a candidate
+ if (elementTop <= scrollY + 50) {
+ currentSection = id;
+ }
+ }
+
+ setActiveSection((prev) => (prev === currentSection ? prev : currentSection));
+ ticking.current = false;
+ });
+ };
+
+ // Initial check
+ handleScroll();
+
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, [sectionIds, offset, activeSection]);
+
+ return activeSection;
+}
diff --git a/frontend/app/components/nav/userProfileStorage.ts b/frontend/app/components/nav/userProfileStorage.ts
new file mode 100644
index 0000000..665e952
--- /dev/null
+++ b/frontend/app/components/nav/userProfileStorage.ts
@@ -0,0 +1,30 @@
+export type UserProfile = {
+ name: string;
+ email: string;
+ twitter: string;
+};
+
+const DEFAULT_PROFILE: UserProfile = { name: '', email: '', twitter: '' };
+
+function key(address: string) {
+ return `agonus:userProfile:${address.toLowerCase()}`;
+}
+
+export function loadUserProfile(address: string): UserProfile {
+ try {
+ const raw = localStorage.getItem(key(address));
+ if (!raw) return DEFAULT_PROFILE;
+ const parsed = JSON.parse(raw);
+ return {
+ name: parsed?.name ?? '',
+ email: parsed?.email ?? '',
+ twitter: parsed?.twitter ?? '',
+ };
+ } catch {
+ return DEFAULT_PROFILE;
+ }
+}
+
+export function saveUserProfile(address: string, profile: UserProfile) {
+ localStorage.setItem(key(address), JSON.stringify(profile));
+}
diff --git a/frontend/app/components/ui/Tooltip.tsx b/frontend/app/components/ui/Tooltip.tsx
new file mode 100644
index 0000000..dd7fa00
--- /dev/null
+++ b/frontend/app/components/ui/Tooltip.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import { ReactNode, useState, useRef } from "react";
+
+interface TooltipProps {
+ children: ReactNode;
+ content: ReactNode;
+ side?: "top" | "bottom" | "left" | "right";
+ delay?: number;
+}
+
+export default function Tooltip({
+ children,
+ content,
+ side = "top",
+ delay = 120,
+}: TooltipProps) {
+ const [visible, setVisible] = useState(false);
+ const triggerRef = useRef(null);
+
+ return (
+ setTimeout(() => setVisible(true), delay)}
+ onMouseLeave={() => setVisible(false)}
+ >
+ {/* Hover Element */}
+ {children}
+
+ {/* Tooltip */}
+ {visible && (
+
+ {content}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/components/ui/agents.tsx b/frontend/app/components/ui/agents.tsx
index d226b5e..e30c7ef 100644
--- a/frontend/app/components/ui/agents.tsx
+++ b/frontend/app/components/ui/agents.tsx
@@ -2,13 +2,12 @@
import { motion } from 'framer-motion';
import { TrendingUp, TrendingDown } from 'lucide-react';
-import {components, animations, typography, spacing, effects} from '../../design-tokens';
+import { animations, typography, effects } from '../../design-tokens';
import {
Card,
CardContent,
CardDescription,
- CardFooter,
CardHeader,
CardTitle,
} from './card';
@@ -23,6 +22,12 @@ export const agents = [
badgeColor: 'blue',
badgeText: 'Passive Investor',
trend: 'up',
+ risk: 'Low',
+ avgHold: '14d',
+ focus: 'BTC/ETH',
+ playstyle: ['Momentum', 'Long Bias'],
+ recentForm: { streak: 'W4', pnl7d: '+6.2%', drawdown: '-2.1%' },
+ metrics: { win: '85%', avgTrade: '+0.8%', vol: 'Med' },
},
{
id: 2,
@@ -33,6 +38,12 @@ export const agents = [
badgeColor: 'red',
badgeText: 'Quick Exit',
trend: 'down',
+ risk: 'Med',
+ avgHold: '2h',
+ focus: 'ALT',
+ playstyle: ['Scalper', 'Risk-Off'],
+ recentForm: { streak: 'L2', pnl7d: '-1.4%', drawdown: '-5.0%' },
+ metrics: { win: '62%', avgTrade: '+0.3%', vol: 'High' },
},
{
id: 3,
@@ -43,6 +54,12 @@ export const agents = [
badgeColor: 'yellow',
badgeText: 'High Risk',
trend: 'up',
+ risk: 'High',
+ avgHold: '6h',
+ focus: 'Memes',
+ playstyle: ['YOLO', 'Volatility'],
+ recentForm: { streak: 'W2', pnl7d: '+4.8%', drawdown: '-7.4%' },
+ metrics: { win: '78%', avgTrade: '+1.6%', vol: 'High' },
},
{
id: 4,
@@ -53,6 +70,12 @@ export const agents = [
badgeColor: 'green',
badgeText: 'Low Risk',
trend: 'up',
+ risk: 'Low',
+ avgHold: '9d',
+ focus: 'ETH',
+ playstyle: ['Mean Reversion', 'Low Vol'],
+ recentForm: { streak: 'W6', pnl7d: '+3.1%', drawdown: '-1.2%' },
+ metrics: { win: '71%', avgTrade: '+0.4%', vol: 'Low' },
},
{
id: 5,
@@ -63,6 +86,12 @@ export const agents = [
badgeColor: 'purple',
badgeText: 'Precise',
trend: 'up',
+ risk: 'Med',
+ avgHold: '1d',
+ focus: 'SOL',
+ playstyle: ['Sniper', 'Breakouts'],
+ recentForm: { streak: 'W5', pnl7d: '+7.9%', drawdown: '-3.3%' },
+ metrics: { win: '92%', avgTrade: '+1.1%', vol: 'Med' },
},
];
@@ -76,6 +105,52 @@ const badgeColors = {
purple: 'bg-purple-400/10 text-purple-400 ring-1 ring-purple-400/30',
};
+const getValueTone = (value?: string) => {
+ if (!value) return 'text-white';
+ if (value.startsWith('-') || value.startsWith('L')) return 'text-red-300';
+ if (value.startsWith('+') || value.startsWith('W')) return 'text-green-300';
+ return 'text-white';
+};
+
+const sectionTooltips = {
+ recentForm: {
+ title: 'Recent Form',
+ body: 'Snapshot of short-term performance: streak, 7-day PnL, and drawdown.',
+ },
+ playstyle: {
+ title: 'Playstyle',
+ body: 'High-level behavior tags that describe how the agent trades.',
+ },
+ keyMetrics: {
+ title: 'Key Metrics',
+ body: 'Core stats used to compare agents at a glance.',
+ },
+ streak: {
+ title: 'Streak',
+ body: 'Current win/loss run. W = wins, L = losses.',
+ },
+ pnl7d: {
+ title: '7d PnL',
+ body: 'Net percentage change over the last 7 days.',
+ },
+ drawdown: {
+ title: 'Drawdown',
+ body: 'Largest peak-to-trough decline in the recent period.',
+ },
+ win: {
+ title: 'Win',
+ body: 'Percent of profitable trades.',
+ },
+ avgTrade: {
+ title: 'Avg Trade',
+ body: 'Average return per trade.',
+ },
+ volatility: {
+ title: 'Volatility',
+ body: 'How much returns vary; higher means more risk.',
+ },
+};
+
export function AgentCard({
agent,
index,
@@ -92,64 +167,211 @@ export function AgentCard({
whileHover={{y:-4}}
className="h-full"
>
-
-
-
- {agent.emoji}
-
-
-
- {agent.name}
-
-
- {agent.strategy}
-
-
+
+
+ {/* Header row: icon + name + short desc + strategy */}
+
+
+
{agent.emoji}
+
+
+ {agent.name}
+
+
+ {agent.strategy}
+
+
+
+ {agent.badgeText}
+
+
+
-
-
- {agent.badgeText}
-
+ {/* Win rate */}
+
+
+
Win Rate
+
+
{agent.winRate}%
+ {agent.trend === 'up' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
-
-
-
Win Rate
-
-
{agent.winRate}%
- {agent.trend === 'up' ? (
-
- ) : (
-
- )}
+
+ {/* Recent Form */}
+
+
+
Recent Form
+
+
i
+
+
+
{sectionTooltips.recentForm.title}
+
{sectionTooltips.recentForm.body}
+
+
+
+
+
+
+
+
+
Streak
+
+
i
+
+
+
{sectionTooltips.streak.title}
+
{sectionTooltips.streak.body}
+
+
+
+
+
{agent.recentForm?.streak}
+
+
+
+
7d PnL
+
+
i
+
+
+
{sectionTooltips.pnl7d.title}
+
{sectionTooltips.pnl7d.body}
+
+
+
+
+
{agent.recentForm?.pnl7d}
+
+
+
+
Drawdown
+
+
i
+
+
+
{sectionTooltips.drawdown.title}
+
{sectionTooltips.drawdown.body}
+
+
+
+
+
{agent.recentForm?.drawdown}
+
+
-
-
+ {/* Playstyle */}
+
+
+
Playstyle
+
+
i
+
+
+
{sectionTooltips.playstyle.title}
+
{sectionTooltips.playstyle.body}
+
+
+
+
+
+
+ {agent.playstyle?.map((tag) => (
+
+ {tag}
+
+ ))}
+
-
-
-
-
- Bet on Agent
-
-
+ {/* Key Metrics */}
+
+
+
Key Metrics
+
+
i
+
+
+
{sectionTooltips.keyMetrics.title}
+
{sectionTooltips.keyMetrics.body}
+
+
+
+
+
+
+
+
+
Win
+
+
i
+
+
+
{sectionTooltips.win.title}
+
{sectionTooltips.win.body}
+
+
+
+
+
{agent.metrics?.win}
+
+
+
+
Avg Trade
+
+
i
+
+
+
{sectionTooltips.avgTrade.title}
+
{sectionTooltips.avgTrade.body}
+
+
+
+
+
{agent.metrics?.avgTrade}
+
+
+
+
Volatility
+
+
i
+
+
+
{sectionTooltips.volatility.title}
+
{sectionTooltips.volatility.body}
+
+
+
+
+
{agent.metrics?.vol}
+
+
+
+
+
);
diff --git a/frontend/app/components/ui/card.tsx b/frontend/app/components/ui/card.tsx
index dbcf8b3..3335df7 100644
--- a/frontend/app/components/ui/card.tsx
+++ b/frontend/app/components/ui/card.tsx
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ {/* Robot body */}
+
+ {/* Antenna */}
+
+
+ {/* Eyes */}
+
+
+
+
+
-function StepCard({ step, index }: { step: (typeof steps)[0]; index: number }) {
- const Icon = step.icon;
+ {/* Floating particles when activated */}
+ {activated && (
+ <>
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+// Step 2: Animated Clock with REAL ticking hands
+function AnimatedClockIcon({ activated }: { activated: boolean }) {
+ const [time, setTime] = useState(0);
+
+ useEffect(() => {
+ if (activated) {
+ const interval = setInterval(() => {
+ setTime(prev => prev + 1);
+ }, 100); // Tick every 100ms for visible motion
+ return () => clearInterval(interval);
+ }
+ }, [activated]);
+
+ const secondsRotation = (time % 60) * 6; // 6 degrees per tick
+ const minutesRotation = Math.floor(time / 60) * 6;
+
+ return (
+
+ {/* Clock face */}
+
+ {/* Inner face */}
+
+ {/* Hour markers */}
+ {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg, i) => (
+
+ ))}
+
+ {/* Center dot */}
+
+
+ {/* Minute hand */}
+
+
+ {/* Second hand - the ticking one */}
+
+
+
+ {/* Alarm bells on top */}
+
+
+
+
+
+
+ {/* Sound waves when activated */}
+ {activated && (
+ <>
+ {[0, 1, 2].map((i) => (
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+// Step 3: Animated Dice with rolling behavior
+function AnimatedDiceIcon({ activated }: { activated: boolean }) {
+ const [diceValue, setDiceValue] = useState(5);
+ const [isRolling, setIsRolling] = useState(false);
+
+ useEffect(() => {
+ if (activated) {
+ // Roll dice periodically
+ const rollInterval = setInterval(() => {
+ setIsRolling(true);
+ // Quick value changes during roll
+ const rollDuration = 600;
+ const changeInterval = setInterval(() => {
+ setDiceValue(Math.floor(Math.random() * 6) + 1);
+ }, 80);
+
+ setTimeout(() => {
+ clearInterval(changeInterval);
+ setIsRolling(false);
+ setDiceValue(Math.floor(Math.random() * 6) + 1);
+ }, rollDuration);
+ }, 3000); // Roll every 3 seconds
+
+ return () => clearInterval(rollInterval);
+ }
+ }, [activated]);
+
+ // Dice dot patterns
+ const dotPatterns: { [key: number]: { x: number; y: number }[] } = {
+ 1: [{ x: 50, y: 50 }],
+ 2: [{ x: 28, y: 28 }, { x: 72, y: 72 }],
+ 3: [{ x: 28, y: 28 }, { x: 50, y: 50 }, { x: 72, y: 72 }],
+ 4: [{ x: 28, y: 28 }, { x: 72, y: 28 }, { x: 28, y: 72 }, { x: 72, y: 72 }],
+ 5: [{ x: 28, y: 28 }, { x: 72, y: 28 }, { x: 50, y: 50 }, { x: 28, y: 72 }, { x: 72, y: 72 }],
+ 6: [{ x: 28, y: 28 }, { x: 28, y: 50 }, { x: 28, y: 72 }, { x: 72, y: 28 }, { x: 72, y: 50 }, { x: 72, y: 72 }],
+ };
return (
-
- {/* Icon and Number at top */}
-
-
-
-
-
- {step.number}
-
+ {/* Dice */}
+
+ {/* Dots */}
+
+ {dotPatterns[diceValue]?.map((dot, i) => (
+
+ ))}
- {/* Title and Description */}
-
-
{step.title}
-
= 5 && (
+
- {step.description}
-
+ 7
+
+ )}
+
+
+ {/* Floating chips when activated */}
+ {activated && (
+ <>
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+ >
+ )}
+
+ );
+}
+
+// Step 4: Trophy/Money icon with BURST animation
+function AnimatedMoneyIcon({ activated, hasPlayedBurst, onBurstComplete }: {
+ activated: boolean;
+ hasPlayedBurst: boolean;
+ onBurstComplete: () => void;
+}) {
+ const [showBurst, setShowBurst] = useState(false);
+
+ useEffect(() => {
+ if (activated && !hasPlayedBurst) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setShowBurst(true);
+ const timer = setTimeout(() => {
+ setShowBurst(false);
+ onBurstComplete();
+ }, 1200);
+ return () => clearTimeout(timer);
+ }
+ }, [activated, hasPlayedBurst, onBurstComplete]);
+
+ return (
+
+ {/* Trophy */}
+
+
+ {/* Shine */}
+
+
+ {/* Trophy icon */}
+
+
+
+
+ {/* Trophy base */}
+
+
+
+ {/* MONEY BURST - only plays once on activation */}
+ {showBurst && (
+ <>
+ {[55, 65, 70, 60, 58, 72, 62, 68].map((distance, i) => {
+ const angle = (i / 8) * Math.PI * 2;
+ return (
+
+ $
+
+ );
+ })}
+ {/* Coins */}
+ {[45, 52, 48, 55, 42, 50].map((distance, i) => {
+ const angle = (i / 6) * Math.PI * 2 + 0.3;
+ return (
+
+ );
+ })}
+ >
+ )}
+
+ {/* Continuous sparkles after burst */}
+ {activated && !showBurst && (
+ <>
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+ >
+ )}
);
}
+// ============================================
+// ICON BUBBLE WRAPPER (3D pop effect)
+// ============================================
+function IconBubble({
+ type,
+ activated,
+ onBurstComplete,
+ hasPlayedBurst,
+}: {
+ type: 'agent' | 'clock' | 'dice' | 'money';
+ activated: boolean;
+ onBurstComplete?: () => void;
+ hasPlayedBurst?: boolean;
+}) {
+ return (
+
+ {/* Dotted yellow ring */}
+
+
+ {/* Glow effect when activated */}
+ {activated && (
+
+ )}
+
+ {/* Icon */}
+ {type === 'agent' && }
+ {type === 'clock' && }
+ {type === 'dice' && }
+ {type === 'money' && (
+ {})}
+ />
+ )}
+
+ );
+}
+
+// ============================================
+// SVG PATH (Treasure map style)
+// ============================================
+const JOURNEY_PATH_D = `
+ M 50 20
+ L 50 60
+ Q 50 80, 30 100
+ L 30 160
+ Q 30 180, 50 200
+ L 70 220
+ Q 90 240, 70 260
+ L 70 320
+ Q 70 340, 50 360
+ L 50 400
+ Q 50 420, 30 440
+ L 30 500
+`;
+
+function JourneyPath({
+ progress,
+ pathRef,
+}: {
+ progress: MotionValue
;
+ pathRef: React.RefObject;
+}) {
+
+ return (
+
+ {/* Dashed background path */}
+
+
+ {/* Glowing progress path */}
+
+
+ {/* Definitions */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// Progress marker that moves along the path
+function ProgressMarker({
+ progress,
+ pathRef,
+}: {
+ progress: MotionValue;
+ pathRef: React.RefObject;
+}) {
+ const xPosition = useTransform(progress, (value) => {
+ const path = pathRef.current;
+ if (!path) return '50%';
+ const length = path.getTotalLength();
+ const point = path.getPointAtLength(value * length);
+ return `${point.x}%`;
+ });
+
+ const yPosition = useTransform(progress, (value) => {
+ const path = pathRef.current;
+ if (!path) return '5%';
+ const length = path.getTotalLength();
+ const point = path.getPointAtLength(value * length);
+ const yPercent = (point.y / 520) * 100;
+ return `${yPercent}%`;
+ });
+
+ return (
+
+
+ {/* Pulse ring */}
+
+
+ {/* Glow */}
+
+
+ {/* Main marker */}
+
+
+
+ );
+}
+
+// ============================================
+// STEP CARD (Glassmorphism)
+// ============================================
+function StepCard({
+ step,
+ index,
+ activated,
+ onActivate,
+ onBurstComplete,
+ hasPlayedBurst,
+}: {
+ step: (typeof steps)[0];
+ index: number;
+ activated: boolean;
+ onActivate: () => void;
+ onBurstComplete?: () => void;
+ hasPlayedBurst?: boolean;
+}) {
+ const ref = useRef(null);
+ const isInView = useInView(ref, { once: true, margin: '-20% 0px -20% 0px' });
+ const isLeft = index % 2 === 0;
+
+ // Trigger activation when in view
+ useEffect(() => {
+ if (isInView && !activated) {
+ onActivate();
+ }
+ }, [isInView, activated, onActivate]);
+
+ const iconTypes: ('agent' | 'clock' | 'dice' | 'money')[] = ['agent', 'clock', 'dice', 'money'];
+
+ return (
+
+ {/* Card */}
+
+
+ {/* Yellow sheen effect */}
+
+
+ {/* Animated border glow when active */}
+ {activated && (
+
+ )}
+
+
+ {/* Top row */}
+
+ {/* Step badge */}
+
+ STEP {step.number}
+
+
+ {/* Icon bubble */}
+
+
+
+ {/* Title */}
+
+ {step.title}
+
+
+ {/* Description */}
+
+ {step.description}
+
+
+ {/* Corner decoration */}
+
+
+
+
+ {/* Connection line to center path */}
+
+
+
+ );
+}
+
+// ============================================
+// MAIN COMPONENT
+// ============================================
export default function Steps() {
+ const containerRef = useRef(null);
+ const pathRef = useRef(null);
+ const [activatedSteps, setActivatedSteps] = useState<{ [key: number]: boolean }>({
+ 0: false, 1: false, 2: false, 3: false
+ });
+ const [moneyBurstPlayed, setMoneyBurstPlayed] = useState(false);
+
+ const { scrollYProgress } = useScroll({
+ target: containerRef,
+ offset: ['start center', 'end center'],
+ });
+ const smoothProgress = useSpring(scrollYProgress, {
+ stiffness: 120,
+ damping: 20,
+ mass: 0.6,
+ });
+
+ const handleActivateStep = (index: number) => {
+ setActivatedSteps(prev => {
+ if (prev[index]) return prev; // Already activated, don't re-trigger
+ return { ...prev, [index]: true };
+ });
+ };
+
return (
-
- {steps.map((step, index) => (
-
- ))}
+
+ {/* Center column with path */}
+
+ {/* Journey path - centered */}
+
+
+ {/* Step cards */}
+
+ {steps.map((step, index) => (
+ handleActivateStep(index)}
+ onBurstComplete={index === 3 ? () => setMoneyBurstPlayed(true) : undefined}
+ hasPlayedBurst={index === 3 ? moneyBurstPlayed : undefined}
+ />
+ ))}
+
+
+
+ {/* Bottom CTA */}
+
+
+
+ Ready to start your journey?
+
+
);
}
diff --git a/frontend/app/components/ui/tournaments.ts b/frontend/app/components/ui/tournaments.ts
new file mode 100644
index 0000000..218f763
--- /dev/null
+++ b/frontend/app/components/ui/tournaments.ts
@@ -0,0 +1,89 @@
+export interface TournamentAgent {
+ id: number;
+ name: string;
+ emoji: string;
+ strategy?: string;
+ personality?: string;
+}
+
+export interface Tournament {
+ id: number;
+ name: string;
+ status: 'LIVE' | 'UPCOMING' | 'ENDED';
+ prize_pool_usd: number;
+ start_time: string;
+ end_time: string;
+ participants: number;
+ max_participants: number;
+ description?: string;
+ agents?: TournamentAgent[];
+}
+
+export const tournaments: Tournament[] = [
+ {
+ id: 5,
+ name: "Winter Trading Championship",
+ status: "LIVE",
+ prize_pool_usd: 1000,
+ start_time: "2026-01-20T00:00:00Z",
+ end_time: "2026-01-28T23:59:59Z",
+ participants: 8,
+ max_participants: 10,
+ description: "High-stakes trading competition with volatile market conditions",
+ agents: [
+ { id: 1, name: "AlphaBot", emoji: "🤖" },
+ { id: 2, name: "TrendHunter", emoji: "🦊" },
+ { id: 3, name: "WhaleWatch", emoji: "🐋" },
+ { id: 4, name: "MomentumX", emoji: "⚡" },
+ { id: 5, name: "DeepValue", emoji: "💎" },
+ ],
+ },
+ {
+ id: 6,
+ name: "Spring Volatility Cup",
+ status: "UPCOMING",
+ prize_pool_usd: 1200,
+ start_time: "2026-02-02T00:00:00Z",
+ end_time: "2026-02-09T23:59:59Z",
+ participants: 4,
+ max_participants: 12,
+ description: "Test your strategies against unpredictable market swings",
+ agents: [
+ { id: 6, name: "SwingTrader", emoji: "🎯" },
+ { id: 7, name: "VolBot", emoji: "📊" },
+ { id: 8, name: "RiskMaster", emoji: "🛡️" },
+ { id: 9, name: "SpeedDemon", emoji: "🏎️" },
+ ],
+ },
+ {
+ id: 7,
+ name: "Momentum Masters",
+ status: "UPCOMING",
+ prize_pool_usd: 1500,
+ start_time: "2026-02-15T00:00:00Z",
+ end_time: "2026-02-22T23:59:59Z",
+ participants: 2,
+ max_participants: 8,
+ description: "Speed and precision matter in this fast-paced tournament",
+ agents: [
+ { id: 10, name: "FlashTrader", emoji: "⚡" },
+ { id: 11, name: "QuickSilver", emoji: "🥈" },
+ ],
+ },
+ {
+ id: 4,
+ name: "February Showdown",
+ status: "ENDED",
+ prize_pool_usd: 800,
+ start_time: "2025-02-01T00:00:00Z",
+ end_time: "2025-02-10T23:59:59Z",
+ participants: 10,
+ max_participants: 10,
+ description: "Completed tournament with fierce competition",
+ agents: [
+ { id: 12, name: "Champion", emoji: "🏆" },
+ { id: 13, name: "SilverStar", emoji: "⭐" },
+ { id: 14, name: "BronzeBeast", emoji: "🥉" },
+ ],
+ },
+];
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index fbc52bc..25c998b 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -97,6 +97,34 @@ body {
}
+/* Glass Card - Glassmorphism card styling */
+.glass-card {
+ background: linear-gradient(135deg, rgba(0, 29, 61, 0.8) 0%, rgba(0, 53, 102, 0.6) 100%);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+}
+
+/* Glass Card with Gold accent - for User Profile */
+.glass-card-gold {
+ background: linear-gradient(135deg, rgba(0, 29, 61, 0.9) 0%, rgba(0, 53, 102, 0.7) 100%);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 2px solid rgba(255, 215, 0, 0.3);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ 0 0 20px rgba(255, 215, 0, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+}
+
+/* Gold Glow effect */
+.glow-gold {
+ box-shadow: 0 0 20px rgba(255, 215, 0, 0.4), 0 0 40px rgba(255, 215, 0, 0.2);
+}
+
/* --- Intro overlay keyframes --- */
@keyframes introOut {
50% {
@@ -321,4 +349,101 @@ body {
transform: translateY(-110vh) rotate(360deg);
opacity: 0;
}
-}
\ No newline at end of file
+}
+
+/* === Background System === */
+.bg-deep {
+ background:
+ radial-gradient(900px 520px at 70% 18%, rgba(255, 215, 0, 0.12), transparent 60%),
+ radial-gradient(900px 580px at 25% 60%, rgba(34, 211, 238, 0.10), transparent 62%),
+ radial-gradient(900px 700px at 50% 95%, rgba(34, 197, 94, 0.08), transparent 60%),
+ linear-gradient(180deg, #041A2F 0%, #031423 45%, #020B15 100%);
+}
+
+.bg-grid {
+ background-image:
+ linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px);
+ background-size: 56px 56px;
+ background-position: center;
+ opacity: 0.10;
+}
+
+.bg-deep-inverse {
+ background:
+ radial-gradient(900px 520px at 30% 82%, rgba(255, 215, 0, 0.12), transparent 60%),
+ radial-gradient(900px 580px at 75% 35%, rgba(34, 211, 238, 0.10), transparent 62%),
+ radial-gradient(900px 700px at 50% 5%, rgba(34, 197, 94, 0.08), transparent 60%),
+ linear-gradient(180deg, #020B15 0%, #031423 55%, #041A2F 100%);
+}
+
+.bg-grid-gold {
+ background-image:
+ linear-gradient(to right, rgba(255,215,0,0.22) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255,215,0,0.22) 1px, transparent 1px);
+ background-size: 56px 56px;
+ background-position: center;
+ opacity: 0.24;
+ filter: drop-shadow(0 0 8px rgba(255,215,0,0.35));
+}
+
+.bg-deep-performance {
+ background: linear-gradient(180deg, #123a63 0%, #0f3257 45%, #0b2b4b 100%);
+}
+
+.bg-grid-gold-bright {
+ background-image:
+ linear-gradient(to right, rgba(255,215,0,0.45) 1.5px, transparent 1px),
+ linear-gradient(to bottom, rgba(255,215,0,0.45) 1.5px, transparent 1px);
+ background-size: 52px 52px;
+ background-position: center;
+ opacity: 0.5;
+ filter: drop-shadow(0 0 12px rgba(255,215,0,0.7));
+}
+
+.section-elevated {
+ box-shadow:
+ 0 30px 60px rgba(2, 11, 21, 0.55),
+ 0 8px 24px rgba(255, 215, 0, 0.08);
+}
+
+.grid-flicker {
+ animation: gridFlicker 5s ease-in-out infinite;
+ will-change: opacity;
+ contain: strict;
+}
+
+@keyframes gridFlicker {
+ 0% { opacity: 0.16; }
+ 45% { opacity: 0.20; }
+ 55% { opacity: 0.12; }
+ 70% { opacity: 0.22; }
+ 100% { opacity: 0.16; }
+}
+
+.bg-noise {
+ position: relative;
+}
+.bg-noise::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.06;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E");
+ mix-blend-mode: overlay;
+ will-change: transform;
+ contain: strict;
+}
+
+@keyframes blipFloat {
+ 0% {
+ transform: translate(0px, 0px) scale(1);
+ }
+ 50% {
+ transform: translate(18px, -14px) scale(1.08);
+ }
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ }
+}
diff --git a/frontend/app/home/components/ActiveBets.tsx b/frontend/app/home/components/ActiveBets.tsx
index db4184a..986f4f5 100644
--- a/frontend/app/home/components/ActiveBets.tsx
+++ b/frontend/app/home/components/ActiveBets.tsx
@@ -1,230 +1,440 @@
'use client';
-import { useState } from 'react';
-import { mockBets } from '../data/mockBet';
+import { useEffect, useState, useMemo, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
-import { Trophy, TrendingUp, XCircle, Clock, Zap } from 'lucide-react';
+import { Trophy, XCircle, Clock, Zap, RefreshCw, Gift } from 'lucide-react';
+import { useAccount, useSwitchChain } from 'wagmi';
+
+import { useWalletAuth } from '@/src/hooks/useWalletAuth';
+import { useBettingStore } from '@/src/store/useBettingStore';
+import { useAgents } from '@/src/hooks/useAgents';
+import { useTournament } from '@/src/hooks/useTournaments';
+import { useClaimStatus, useClaimWinningsOnchain } from '@/src/hooks/useOnchainBetting';
+import { AGONUS_CHAIN_ID } from '@/src/lib/agonusContract';
+import { settleBet } from '@/src/lib/api/bets';
+import type { Bet } from '@/src/types/bets';
-// ADD THIS INTERFACE
interface ActiveBetsProps {
tournamentId: string;
}
-// UPDATE THIS LINE TO ACCEPT THE PROP
+type UiStatus = 'active' | 'won' | 'lost';
+
+const statusLabel: Record
= {
+ active: 'Active',
+ won: 'Won',
+ lost: 'Lost',
+};
+
+const statusIcon = (uiStatus: UiStatus) => {
+ if (uiStatus === 'won') return Trophy;
+ if (uiStatus === 'lost') return XCircle;
+ return Clock;
+};
+
export default function ActiveBets({ tournamentId }: ActiveBetsProps) {
const [filter, setFilter] = useState<'active' | 'past'>('active');
+ const { isConnected } = useAccount();
+ const { isAuthenticated, isSigningIn, signInError, signIn } = useWalletAuth();
+ const myBets = useBettingStore((s) => s.myBets);
+ const isLoadingBets = useBettingStore((s) => s.isLoadingBets);
+ const betsError = useBettingStore((s) => s.betsError);
+ const refreshMyBets = useBettingStore((s) => s.refreshMyBets);
- // Filter bets based on status AND tournament_id
- const activeBets = mockBets.filter(
- (b) => b.status === 'active' && b.tournament_id.toString() === tournamentId
- );
- const pastBets = mockBets.filter(
- (b) =>
- (b.status === 'won' || b.status === 'lost') &&
- b.tournament_id.toString() === tournamentId
+ // Fetch all agents to get names/avatars
+ const { data: agents } = useAgents();
+ const { data: tournament } = useTournament(tournamentId);
+
+ // On-chain claim status
+ const contractTournamentId = tournament?.contract_tournament_id;
+ const isTournamentSettled = tournament?.status === 'completed';
+ const {
+ claimed: alreadyClaimed,
+ payoutEth,
+ hasClaimable,
+ isLoading: claimStatusLoading,
+ refetch: refetchClaimStatus,
+ } = useClaimStatus(isTournamentSettled ? contractTournamentId : null);
+
+ const claimWinningsOnchain = useClaimWinningsOnchain();
+ const [claimState, setClaimState] = useState<'idle' | 'confirming' | 'pending' | 'success' | 'error'>('idle');
+ const [claimError, setClaimError] = useState(null);
+
+ const { chainId } = useAccount();
+ const wrongNetwork = isConnected && chainId !== AGONUS_CHAIN_ID;
+ const { switchChain, isPending: isSwitching } = useSwitchChain();
+
+ const handleClaim = useCallback(async () => {
+ if (!contractTournamentId || wrongNetwork) return;
+ try {
+ setClaimState('confirming');
+ setClaimError(null);
+ await claimWinningsOnchain(contractTournamentId);
+ setClaimState('success');
+ refetchClaimStatus();
+
+ // Sync backend: mark bets as settled so they move to "Past"
+ const winnerId = tournament?.winner_agent_id;
+ const totalPayout = parseFloat(payoutEth) || 0;
+ const betsForTournament = myBets.filter(
+ (b) => String(b.tournament_id) === String(tournamentId) && !b.settled
+ );
+ // Calculate total amount bet on the winner to split payout proportionally
+ const winningBets = betsForTournament.filter(
+ (b) => winnerId && String(b.agent_id) === String(winnerId)
+ );
+ const totalWinningAmount = winningBets.reduce(
+ (sum, b) => sum + (parseFloat(String(b.amount_eth ?? b.amount)) || 0), 0
+ );
+
+ await Promise.allSettled(
+ betsForTournament.map((bet) => {
+ const isWinner = winnerId && String(bet.agent_id) === String(winnerId);
+ const betAmount = parseFloat(String(bet.amount_eth ?? bet.amount)) || 0;
+ const betPayout = isWinner && totalWinningAmount > 0
+ ? (betAmount / totalWinningAmount) * totalPayout
+ : 0;
+ return settleBet(String(bet.id), betPayout);
+ })
+ );
+ refreshMyBets(tournamentId);
+ } catch (err: unknown) {
+ console.error('Claim failed:', err);
+ setClaimState('error');
+ setClaimError((err as Error)?.message || 'Claim transaction failed');
+ }
+ }, [contractTournamentId, wrongNetwork, claimWinningsOnchain, refetchClaimStatus, tournament, payoutEth, myBets, tournamentId, refreshMyBets]);
+
+ // Create a lookup map: agent_id -> agent data
+ const agentMap = useMemo(() => {
+ if (!agents) return new Map();
+ return new Map(agents.map((a) => [String(a.id), { name: a.name, strategy_type: a.strategy_type }]));
+ }, [agents]);
+
+ useEffect(() => {
+ if (!isConnected || !isAuthenticated) return;
+ refreshMyBets(tournamentId);
+ }, [isConnected, isAuthenticated, refreshMyBets, tournamentId]);
+
+ const scopedBets = myBets.filter(
+ (b) => String(b.tournament_id) === String(tournamentId)
);
- const displayedBets = filter === 'active' ? activeBets : pastBets;
-
- // Get agent avatar color based on name
- const getAgentColor = (agent: string) => {
- const colors: { [key: string]: string } = {
- 'Diamond Hands Dan': 'from-blue-500 to-cyan-500',
- 'YOLO Trader': 'from-purple-500 to-pink-500',
- 'Sniper Bot': 'from-green-500 to-emerald-500',
- 'Paper Hands Pete': 'from-orange-500 to-red-500',
- 'Safe Mode Sarah': 'from-indigo-500 to-blue-500',
- };
- return colors[agent] || 'from-gray-500 to-slate-500';
+ // Determine bet status based on tournament state, not just backend settled field
+ const winnerId = tournament?.winner_agent_id;
+ const getBetUiStatus = (bet: Bet): UiStatus => {
+ // If tournament is completed, use winner to determine won/lost
+ if (isTournamentSettled) {
+ if (winnerId && String(bet.agent_id) === String(winnerId)) return 'won';
+ return 'lost';
+ }
+ // If bet is already settled in backend
+ if (bet.settled) {
+ return bet.payout && Number(bet.payout) > 0 ? 'won' : 'lost';
+ }
+ if (bet.status === 'CANCELED' || bet.status === 'FAILED') return 'lost';
+ return 'active';
};
- return (
-
- {/* Background glow */}
-
+ const active = scopedBets.filter((b) => getBetUiStatus(b) === 'active');
+ const past = scopedBets.filter((b) => getBetUiStatus(b) !== 'active');
+ const displayed = filter === 'active' ? active : past;
- {/* Header */}
-
+ return (
+
+ {/* HEADER */}
+
Your Bets
-
Tournament #{tournamentId}
+
{tournament?.name || 'Loading...'}
- {/* Total stats badge */}
-
-
- {displayedBets.length} Bets
-
+
+
refreshMyBets(tournamentId)}
+ disabled={!isConnected || !isAuthenticated || isLoadingBets}
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-white/10 text-xs text-gray-200 hover:bg-white/5 disabled:opacity-50"
+ title="Refresh bets"
+ >
+
+ Refresh
+
+
+
+ {displayed.length} Bets
+
+
- {/* Filter Tabs */}
-
+ {/* FILTER TABS */}
+
setFilter('active')}
- className={`flex-1 px-4 py-2 rounded-md text-sm font-semibold transition-all ${
+ className={`flex-1 px-4 py-2 rounded-md text-sm font-semibold ${
filter === 'active'
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'
: 'text-gray-400 hover:text-white'
}`}
>
- Active ({activeBets.length})
+ Active ({active.length})
+
setFilter('past')}
- className={`flex-1 px-4 py-2 rounded-md text-sm font-semibold transition-all ${
+ className={`flex-1 px-4 py-2 rounded-md text-sm font-semibold ${
filter === 'past'
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'
: 'text-gray-400 hover:text-white'
}`}
>
- Past ({pastBets.length})
+ Past ({past.length})
- {/* Bets Display - Card Grid */}
-
- {displayedBets.length === 0 ? (
-
-
-
-
-
No {filter} bets
-
- ) : (
-
-
-
- {displayedBets.map((bet, index) => (
-
+
+ {!isConnected ? (
+
+
+ Connect your wallet to place and view bets.
+
+
+ ) : !isAuthenticated ? (
+
+
+
+
Sign in required
+
+ Sign a message to access betting features.
+
+ {signInError && (
+
{signInError}
+ )}
+
+
- {/* Hover glow effect */}
-
+ {isSigningIn ? 'Signing...' : 'Sign In'}
+
+
+
+ ) : betsError ? (
+
+ {betsError}
+
+ ) : isLoadingBets ? (
+
+ Loading bets...
+
+ ) : displayed.length === 0 ? (
+
+ No bets yet.
+
+ ) : (
+ displayed.map((bet, index) => (
+
+ {/* TOP ROW */}
+
+ {/* Agent bubble */}
+ {(() => {
+ const agentData = agentMap.get(String(bet.agent_id));
+ const agentName = bet.agent_name || agentData?.name || `Agent`;
+ const agentType = agentData?.strategy_type || '';
+ // Generate avatar URL using DiceBear (same as mock data)
+ const avatarUrl = `https://api.dicebear.com/7.x/bottts/svg?seed=${encodeURIComponent(agentName)}`;
-
- {/* Top row: Agent info & Status */}
-
+ return (
- {/* Agent avatar */}
-
- {bet.agent
- .split(' ')
- .map((w) => w[0])
- .join('')}
+
+
+
- {bet.agent}
-
-
- Tournament #{bet.tournament_id}
+ {agentName}
+
+ {agentType && {agentType.replace('_', ' ')} }
+ {agentType && • }
+ {bet.status}
+
+ );
+ })()}
- {/* Status badge */}
- {bet.status === 'active' && (
-
-
-
- Active
-
-
- )}
- {bet.status === 'won' && (
-
-
-
- Won
-
-
- )}
- {bet.status === 'lost' && (
-
-
-
- Lost
-
-
- )}
-
-
- {/* Bottom row: Bet amount */}
-
-
Bet Amount
-
- {bet.amount_eth} ETH
-
-
-
-
- ))}
-
-
- )}
+ {/* Status Badge */}
+ {(() => {
+ const uiStatus = getBetUiStatus(bet);
+ const Icon = statusIcon(uiStatus);
+ const color =
+ uiStatus === 'won'
+ ? 'bg-green-500/20 border-green-500/30 text-green-400'
+ : uiStatus === 'lost'
+ ? 'bg-red-500/20 border-red-500/30 text-red-400'
+ : 'bg-cyan-500/20 border-cyan-500/30 text-cyan-400';
+
+ return (
+
+
+
+ {statusLabel[uiStatus]}
+
+
+ );
+ })()}
+
+
+ {/* AMOUNT + TIMESTAMP */}
+
+
Bet Amount
+
+ {bet.amount_eth || bet.amount || '0'} ETH
+
+
+ Placed:{' '}
+ {(() => {
+ const placed = bet.created_at ?? bet.placed_at;
+ return placed ? new Date(placed).toLocaleString() : 'Unknown';
+ })()}
+
+
+
+ ))
+ )}
+
- {/* Summary footer for past bets */}
- {filter === 'past' && pastBets.length > 0 && (
-
-
-
-
-
-
Won
+ {/* CLAIM WINNINGS BANNER */}
+ {isConnected && isAuthenticated && isTournamentSettled && contractTournamentId && scopedBets.length > 0 && (
+
+ {claimStatusLoading ? (
+
+
Checking claim status...
+
+ ) : alreadyClaimed ? (
+
+
-
- {pastBets.filter((b) => b.status === 'won').length}
+
+ You have already claimed your winnings for this tournament.
+ ) : hasClaimable ? (
+
+
+
+
+
+
Claim Your Winnings
+
Tournament settled
+
+
+
+
+
+ {wrongNetwork && (
+
switchChain({ chainId: AGONUS_CHAIN_ID })}
+ disabled={isSwitching}
+ className="w-full bg-red-500/20 border border-red-700 text-red-300 text-sm p-2.5 rounded-lg mb-3 hover:bg-red-500/30 transition font-medium"
+ >
+ {isSwitching ? 'Switching...' : `Switch to Base Sepolia to claim`}
+
+ )}
+
+ {claimState === 'error' && claimError && (
+
+ {claimError}
+
+ )}
-
-
-
-
Lost
+ {claimState === 'success' ? (
+
+ Winnings claimed successfully!
+
+ ) : (
+
+ {claimState === 'confirming'
+ ? 'Confirm in Wallet...'
+ : claimState === 'pending'
+ ? 'Claiming...'
+ : 'Claim Winnings'}
+
+ )}
+
+ ) : (
+
+
+
+
No winnings to claim for this tournament.
-
- {pastBets.filter((b) => b.status === 'lost').length}
-
-
+ )}
)}
-
- {/* Bottom accent line */}
-
);
}
diff --git a/frontend/app/home/components/AgentPerformanceChart.tsx b/frontend/app/home/components/AgentPerformanceChart.tsx
index f054e4c..ecbd6ed 100644
--- a/frontend/app/home/components/AgentPerformanceChart.tsx
+++ b/frontend/app/home/components/AgentPerformanceChart.tsx
@@ -9,12 +9,45 @@ import {
EyeOff,
Maximize2,
Minimize2,
+ Filter,
+ X,
+ Brain,
+ DollarSign,
+ Target,
+ TrendingDown,
+ Activity,
+ Coins,
+ Info,
+ Percent,
} from 'lucide-react';
import type { UTCTimestamp, ISeriesApi, IChartApi } from 'lightweight-charts';
-import { LineSeries } from 'lightweight-charts';
import { useTournamentAgentStates } from '@/src/hooks/useAgentStates';
import { useAgents } from '@/src/hooks/useAgents';
-import { useAgentTrades } from '@/src/hooks/useTrades';
+import { AgentState } from '@/src/types';
+import { findAgentById } from '@/src/util/findAgentById';
+
+// Tooltip component for explaining terms
+function InfoTooltip({ text }: { text: string }) {
+ const [show, setShow] = useState(false);
+
+ return (
+
+
setShow(true)}
+ onMouseLeave={() => setShow(false)}
+ />
+ {show && (
+
+ )}
+
+ );
+}
interface AgentPerformanceChartProps {
tournamentId: string;
@@ -51,6 +84,11 @@ export default function AgentPerformanceChart({
const chartContainerRef = useRef
(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [visibleAgents, setVisibleAgents] = useState>(new Set());
+ const [showFilterMenu, setShowFilterMenu] = useState(false);
+ const [selectedAgent, setSelectedAgent] = useState(null);
+ const [chartMode, setChartMode] = useState<'absolute' | 'relative'>('absolute');
+ const chartRef = useRef(null);
+ const seriesMapRef = useRef>>(new Map());
// Fetch data from backend
const { data: agentStates, isLoading: statesLoading } =
@@ -67,7 +105,7 @@ export default function AgentPerformanceChart({
// Create performance lines for each agent
const lines: AgentPerformanceLine[] = agentStates.map((state, index) => {
- const agent = agents.find((a) => a.id === state.agent_id);
+ const agent = findAgentById(agents, state.agent_id);
const agentName = agent?.name || `Agent ${index + 1}`;
// For now, we'll create a simple time series based on current state
@@ -80,14 +118,21 @@ export default function AgentPerformanceChart({
const dataPoints = 50;
const startTime = currentTime - dataPoints * 300; // 5 min intervals
+ // Use seeded random based on agent_id for consistent data across renders
+ const seed = state.agent_id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+ const seededRandom = (i: number) => {
+ const x = Math.sin(seed + i) * 10000;
+ return x - Math.floor(x);
+ };
+
for (let i = 0; i < dataPoints; i++) {
const time = (startTime + i * 300) as UTCTimestamp;
// Simulate growth trend towards current value
const progress = i / dataPoints;
const startValue = portfolioValue * 0.8; // Start at 80% of current
const value = startValue + (portfolioValue - startValue) * progress;
- // Add some randomness
- const noise = (Math.random() - 0.5) * portfolioValue * 0.05;
+ // Use seeded randomness for consistent results
+ const noise = (seededRandom(i) - 0.5) * portfolioValue * 0.05;
data.push({
time,
value: value + noise,
@@ -142,16 +187,17 @@ export default function AgentPerformanceChart({
const container = chartContainerRef.current;
if (!container || performanceLines.length === 0) return;
- let chart: IChartApi | null = null;
- const seriesMap: Map> = new Map();
+ let disposed = false;
+ let resizeHandler: (() => void) | undefined;
+ let chartInstance: IChartApi | null = null;
import('lightweight-charts').then((LightweightCharts) => {
- if (!chartContainerRef.current) return;
+ if (disposed || !chartContainerRef.current) return;
const ctn = chartContainerRef.current;
ctn.innerHTML = '';
- chart = LightweightCharts.createChart(ctn, {
+ const chart = LightweightCharts.createChart(ctn, {
width: ctn.clientWidth,
height: isFullscreen ? window.innerHeight - 100 : 450,
layout: {
@@ -168,62 +214,83 @@ export default function AgentPerformanceChart({
},
rightPriceScale: {
borderColor: 'rgba(255,255,255,0.1)' as string,
+ minimumWidth: 64,
},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
},
});
+ chartInstance = chart;
+ chartRef.current = chart;
+ seriesMapRef.current.clear();
+
// Add a line series for each agent
performanceLines.forEach((line) => {
if (!chart) return;
- const series = chart.addSeries(LineSeries, {
+ const series = chart.addSeries(LightweightCharts.LineSeries, {
color: line.color,
lineWidth: 2,
+ visible: visibleAgents.has(line.agentId),
});
- series.setData(line.data);
- seriesMap.set(line.agentId, series);
+ // Transform data based on chart mode
+ let chartData = line.data;
+ if (chartMode === 'relative' && line.data.length > 0) {
+ const startValue = line.data[0].value;
+ chartData = line.data.map((point) => ({
+ time: point.time,
+ value: startValue !== 0 ? ((point.value - startValue) / startValue) * 100 : 0,
+ }));
+ }
+
+ series.setData(chartData);
+ seriesMapRef.current.set(line.agentId, series);
});
chart.timeScale().fitContent();
- const handleResize = () => {
- if (!chart || !chartContainerRef.current) return;
+ resizeHandler = () => {
+ if (disposed || !chartContainerRef.current) return;
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
height: isFullscreen ? window.innerHeight - 100 : 450,
});
};
- window.addEventListener('resize', handleResize);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- chart?.remove();
- chart = null;
- };
+ window.addEventListener('resize', resizeHandler);
});
return () => {
- chart?.remove();
- chart = null;
+ disposed = true;
+ if (resizeHandler) window.removeEventListener('resize', resizeHandler);
+ if (chartInstance) {
+ chartInstance.remove();
+ chartRef.current = null;
+ }
if (container) container.innerHTML = '';
};
- }, [performanceLines, isFullscreen]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [performanceLines, isFullscreen, chartMode]);
- // Update visibility
+ // Update visibility when visibleAgents changes
useEffect(() => {
+ if (!seriesMapRef.current || seriesMapRef.current.size === 0) return;
+
performanceLines.forEach((line) => {
- // This would update the series visibility if lightweight-charts supported it
- // For now, we'll just track it in state and filter the data
+ const series = seriesMapRef.current.get(line.agentId);
+ if (series) {
+ series.applyOptions({
+ visible: visibleAgents.has(line.agentId),
+ });
+ }
});
}, [visibleAgents, performanceLines]);
if (statesLoading || agentsLoading) {
return (
-
+
Loading agent performance data...
@@ -243,21 +310,19 @@ export default function AgentPerformanceChart({
}
>
{/* Header */}
-
+
-
+
Agent Performance
@@ -267,30 +332,129 @@ export default function AgentPerformanceChart({
- {/* Legend / Agent toggles */}
-
- {performanceLines.slice(0, 4).map((line) => (
-
toggleAgent(line.agentId)}
- className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${
- visibleAgents.has(line.agentId)
- ? 'bg-white/10 border-white/20'
- : 'bg-white/5 border-white/10 opacity-50'
- }`}
- >
-
- {line.agentName}
- {visibleAgents.has(line.agentId) ? (
-
- ) : (
-
- )}
-
- ))}
+ {/* Absolute/Relative Toggle */}
+
+
setChartMode('absolute')}
+ className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
+ chartMode === 'absolute'
+ ? 'bg-[#FFD700] text-[#001D3D]'
+ : 'text-gray-400 hover:text-white'
+ }`}
+ title="Show absolute USD values"
+ >
+
+ USD
+
+
setChartMode('relative')}
+ className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
+ chartMode === 'relative'
+ ? 'bg-[#FFD700] text-[#001D3D]'
+ : 'text-gray-400 hover:text-white'
+ }`}
+ title="Show percentage change from start"
+ >
+
+ Change
+
+
+
+ {/* Agent Filter Dropdown */}
+
+
setShowFilterMenu(!showFilterMenu)}
+ >
+
+ {visibleAgents.size < performanceLines.length && (
+
+ !
+
+ )}
+
+
+ {/* Filter dropdown menu */}
+ {showFilterMenu && (
+
+
+
Agent Visibility
+ setShowFilterMenu(false)}
+ className="text-gray-400 hover:text-white transition-colors"
+ >
+
+
+
+
+ {/* Select All / Deselect All */}
+
+ setVisibleAgents(new Set(performanceLines.map(l => l.agentId)))}
+ className="flex-1 px-3 py-2 rounded-lg text-xs font-medium bg-white/5 text-gray-400 border border-white/10 hover:bg-white/10"
+ >
+ Select All
+
+ setVisibleAgents(new Set())}
+ className="flex-1 px-3 py-2 rounded-lg text-xs font-medium bg-white/5 text-gray-400 border border-white/10 hover:bg-white/10"
+ >
+ Deselect All
+
+
+
+ {/* Agent list */}
+
+ {performanceLines.map((line) => {
+ const agentState = agentStates?.find(s => s.agent_id === line.agentId);
+ return (
+
+
toggleAgent(line.agentId)}
+ className="flex items-center gap-3 flex-1"
+ >
+
+ {line.agentName}
+
+ {visibleAgents.has(line.agentId) ? (
+
+ ) : (
+
+ )}
+
+
+
{
+ if (agentState) {
+ setSelectedAgent(agentState);
+ setShowFilterMenu(false);
+ }
+ }}
+ className="p-1.5 hover:bg-white/10 rounded transition-colors"
+ title="View agent details"
+ >
+
+
+
+ );
+ })}
+
+
+ )}
- Portfolio Value
+ {chartMode === 'absolute' ? 'Portfolio Value' : 'Performance'}
- ${topAgentValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+ {chartMode === 'absolute'
+ ? `$${topAgentValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
+ : (() => {
+ const startVal = topAgent?.data[0]?.value || 0;
+ const pctChange = startVal !== 0 ? ((topAgentValue - startVal) / startVal) * 100 : 0;
+ return `${pctChange >= 0 ? '+' : ''}${pctChange.toFixed(2)}%`;
+ })()
+ }
@@ -355,6 +526,147 @@ export default function AgentPerformanceChart({
+
+ {/* Agent Detail Modal */}
+ {selectedAgent && (() => {
+ const agent = findAgentById(agents, selectedAgent.agent_id);
+ const initialValue = 10000; // Placeholder - should come from backend
+ const portfolioValue = parseFloat(selectedAgent.portfolio_value_usd);
+ const pnl = portfolioValue - initialValue;
+ const roi = (pnl / initialValue) * 100;
+ const isPositive = pnl >= 0;
+
+ return (
+
setSelectedAgent(null)}
+ >
+
e.stopPropagation()}
+ className="bg-gray-900/95 backdrop-blur-xl border border-white/20 rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"
+ >
+ {/* Close Button */}
+ setSelectedAgent(null)}
+ className="absolute top-4 right-4 p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
+ >
+
+
+
+ {/* Agent Header */}
+
+
+
+
+
+
+
{agent?.name || 'Unknown Agent'}
+
+
+ Rank #{selectedAgent.rank}
+
+
+
{agent?.strategy_type || 'Strategy N/A'}
+
{agent?.personality || 'No personality defined'}
+
+
+
+ {/* Performance Metrics */}
+
+
+
+
+ Portfolio Value
+
+
+ ${portfolioValue.toLocaleString()}
+
+
+
+
+
+
+
+ ROI
+
+
+
+
+ {isPositive ? '+' : ''}{roi.toFixed(2)}%
+
+
+
+
+
+ {isPositive ? (
+
+ ) : (
+
+ )}
+
+ Profit & Loss
+
+
+
+
+ {isPositive ? '+' : ''}${Math.abs(pnl).toLocaleString()}
+
+
+
+
+
+
{selectedAgent.trades_count}
+
+
+
+ {/* Portfolio Holdings */}
+
+
+
+ Portfolio Holdings
+
+
+ {Object.keys(selectedAgent.portfolio).length > 0 ? (
+
+ {Object.entries(selectedAgent.portfolio).map(([asset, quantity]) => (
+
+ {asset}
+ {quantity.toLocaleString()}
+
+ ))}
+
+ ) : (
+
No assets in portfolio
+ )}
+
+
+
+ {/* Last Decision */}
+
+
+
+ Latest Decision
+
+
+
{selectedAgent.last_decision || 'No decision recorded yet'}
+
+ Updated: {new Date(selectedAgent.updated_at).toLocaleString()}
+
+
+
+
+
+ );
+ })()}
);
}
diff --git a/frontend/app/home/components/AgentPositions.tsx b/frontend/app/home/components/AgentPositions.tsx
index 0923c62..fbb8762 100644
--- a/frontend/app/home/components/AgentPositions.tsx
+++ b/frontend/app/home/components/AgentPositions.tsx
@@ -1,37 +1,90 @@
'use client';
+import { useState } from 'react';
import { motion } from 'framer-motion';
import {
Wallet,
- TrendingUp,
- TrendingDown,
PieChart,
Sparkles,
+ X,
+ Brain,
+ Activity,
+ Clock,
+ Target,
} from 'lucide-react';
import { useTournamentAgentStates } from '@/src/hooks/useAgentStates';
import { useAgents } from '@/src/hooks/useAgents';
+import { useTournament } from '@/src/hooks/useTournaments';
+import { AgentState, Agent } from '@/src/types';
+import { useBettingStore } from '@/src/store/useBettingStore';
+import { useTournamentStore } from '@/src/store/useTournamentStore';
+import { findAgentById } from '@/src/util/findAgentById';
interface AgentPositionsProps {
tournamentId: string;
}
+interface SelectedAgentData {
+ agentState: AgentState;
+ agent: Agent | undefined;
+ portfolioValue: number;
+ percentOfTotal: number;
+}
+
export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
// Fetch real data from backend
const { data: agentStates, isLoading: statesLoading } =
useTournamentAgentStates(tournamentId);
const { data: agents, isLoading: agentsLoading } = useAgents();
+ const { data: tournament } = useTournament(tournamentId);
+ const openBetModal = useBettingStore((s) => s.openBetModal);
+ const tournamentStatus = useTournamentStore((s) => s.selectedTournamentStatus);
const isLoading = statesLoading || agentsLoading;
+ // State for selected agent modal
+ const [selectedAgent, setSelectedAgent] = useState
(null);
+
+ // Build fallback agent states from agent_contract_mapping when backend hasn't initialized states yet
+ const hasLiveStates = agentStates && agentStates.length > 0;
+ const mappedAgentIds = tournament?.agent_contract_mapping ? Object.keys(tournament.agent_contract_mapping) : [];
+ const hasMappedAgents = mappedAgentIds.length > 0;
+
+ const fallbackAgentStates: AgentState[] = (!hasLiveStates && hasMappedAgents)
+ ? mappedAgentIds.map((agentId, idx) => ({
+ agent_id: agentId,
+ tournament_id: tournamentId,
+ portfolio: {},
+ portfolio_value_usd: '0',
+ rank: idx + 1,
+ trades_count: 0,
+ last_decision: 'Waiting to start trading',
+ updated_at: new Date().toISOString(),
+ }))
+ : [];
+
+ const displayedStates = hasLiveStates ? agentStates : fallbackAgentStates;
+ const isFallback = !hasLiveStates && fallbackAgentStates.length > 0;
+
// calculate total portfolio value for this tournament
const totalValue =
- agentStates?.reduce(
+ displayedStates.reduce(
(sum, state) => sum + parseFloat(state.portfolio_value_usd),
0,
) || 0;
+ // Handle agent click
+ const handleAgentClick = (agentState: AgentState, agent: Agent | undefined, portfolioValue: number, percentOfTotal: number) => {
+ setSelectedAgent({ agentState, agent, portfolioValue, percentOfTotal });
+ };
+
+ // Close modal
+ const closeModal = () => {
+ setSelectedAgent(null);
+ };
+
return (
Agent Positions
-
Tournament #{tournamentId}
+
{tournament?.name || 'Loading...'}
@@ -63,27 +116,32 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
{/* positions list */}
-
+
{isLoading ? (
- ) : !agentStates || agentStates.length === 0 ? (
+ ) : displayedStates.length === 0 ? (
No agents for this tournament
) : (
- agentStates.map((agentState, index) => {
- const agent = agents?.find((a) => a.id === agentState.agent_id);
+ <>
+ {isFallback && (
+
+
Agents assigned but not yet trading. Data will update once the tournament starts.
+
+ )}
+ {displayedStates.map((agentState, index) => {
+ const agent = findAgentById(agents, agentState.agent_id);
const agentName = agent?.name || `Agent ${index + 1}`;
const portfolioValue = parseFloat(agentState.portfolio_value_usd);
const percentOfTotal =
totalValue > 0 ? (portfolioValue / totalValue) * 100 : 0;
- // Get top asset from portfolio
- const portfolioEntries = Object.entries(
- agentState.portfolio as Record
,
- );
+ // Get top asset from portfolio holdings
+ const holdings = (agentState.portfolio as { holdings?: Record })?.holdings || {};
+ const portfolioEntries = Object.entries(holdings) as [string, number][];
const topAsset =
portfolioEntries.length > 0
? portfolioEntries.reduce((max, current) =>
@@ -94,11 +152,11 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
return (
handleAgentClick(agentState, agent, portfolioValue, percentOfTotal)}
>
{/* background gradient on hover */}
@@ -107,17 +165,13 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
{/* agent info row */}
- {/* agent avatar or icon */}
-
- {agent?.avatar_url ? (
-
- ) : (
- agentName[0].toUpperCase()
- )}
+ {/* agent avatar using DiceBear */}
+
+
@@ -129,19 +183,52 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
- {/* value and rank */}
-
-
- ${portfolioValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
-
-
- {topAsset && (
- <>
- Top: {topAsset[0]}
- >
- )}
-
+ {/* value and actions */}
+
+
+ ${portfolioValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
+
+ {topAsset && (
+ <>
+ Top: {topAsset[0]}
+ >
+ )}
+ {tournamentStatus === 'LIVE' && !tournament?.betting_closed ? (
+
{
+ e.stopPropagation();
+ openBetModal({
+ tournament_id: tournamentId,
+ tournament_name: tournament?.name,
+ agent_id: agentState.agent_id,
+ agent_name: agentName,
+ contract_tournament_id: tournament?.contract_tournament_id ?? null,
+ contract_agent_id:
+ tournament?.agent_contract_mapping?.[agentState.agent_id] ?? null,
+ });
+ }}
+ className="mt-2 inline-flex items-center justify-center rounded-lg bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 text-xs font-semibold text-cyan-300 hover:bg-cyan-500/30 transition"
+ >
+ Place Bet
+
+ ) : (
+
+ {tournamentStatus === 'ENDED'
+ ? 'Tournament Ended'
+ : tournament?.betting_closed
+ ? 'Betting Closed'
+ : 'Betting Opens Soon'}
+
+ )}
+
{/* progress bar */}
@@ -162,18 +249,10 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
animate={{ width: `${percentOfTotal}%` }}
transition={{ duration: 1, delay: index * 0.1 }}
>
- {/* shimmer effect */}
-
@@ -181,12 +260,13 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
);
- })
+ })}
+ >
)}
{/* total portfolio summary */}
- {agentStates && agentStates.length > 0 && (
+ {displayedStates.length > 0 && !isFallback && (
@@ -201,7 +281,7 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
${totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
- {agentStates.length} agents
+ {displayedStates.length} agents
@@ -211,6 +291,142 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) {
{/* bottom accent line */}
+
+ {/* Agent Detail Modal */}
+ {selectedAgent && (
+
+
e.stopPropagation()}
+ >
+ {/* Modal Header */}
+
+
+
+
+
+
+
+ {selectedAgent.agent?.name || 'Unknown Agent'}
+
+
+ Rank #{selectedAgent.agentState.rank}
+
+
+
+
+
+
+
+
+ {/* Modal Content */}
+
+ {/* Portfolio Value */}
+
+
+
+ Portfolio Value
+
+
+ ${selectedAgent.portfolioValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
+
+ {selectedAgent.percentOfTotal.toFixed(1)}% of tournament total
+
+
+
+ {/* Stats Grid */}
+
+
+
+
+ {selectedAgent.agentState.trades_count}
+
+
+
+
+
+
+ Strategy
+
+
+ {selectedAgent.agent?.strategy_type || 'N/A'}
+
+
+
+
+ {/* Last Decision */}
+
+
+
+ Last Decision
+
+
+ {selectedAgent.agentState.last_decision || 'No decision recorded'}
+
+
+
+ {/* Personality */}
+ {selectedAgent.agent?.personality && (
+
+
+
+ Personality
+
+
+ {selectedAgent.agent.personality}
+
+
+ )}
+
+ {/* Portfolio Holdings - Only show actual crypto holdings, not all portfolio fields */}
+ {(() => {
+ const portfolio = selectedAgent.agentState.portfolio as { holdings?: Record
};
+ const holdings = portfolio?.holdings || {};
+ const hasHoldings = Object.keys(holdings).length > 0;
+
+ return hasHoldings && (
+
+
+
+ {Object.entries(holdings).map(([asset, amount]) => (
+
+ {asset}
+ {(amount as number).toLocaleString()}
+
+ ))}
+
+
+ );
+ })()}
+
+ {/* Updated At */}
+
+
+
+ Last updated: {new Date(selectedAgent.agentState.updated_at).toLocaleString()}
+
+
+
+
+
+ )}
);
}
diff --git a/frontend/app/home/components/CandleChart.tsx b/frontend/app/home/components/CandleChart.tsx
index 6fc2cae..6875292 100644
--- a/frontend/app/home/components/CandleChart.tsx
+++ b/frontend/app/home/components/CandleChart.tsx
@@ -1,298 +1,315 @@
'use client';
-import { useEffect, useRef, useState } from 'react';
+import { useMemo, useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
+import { Maximize2, Minimize2 } from 'lucide-react';
+
import {
- TrendingUp,
- Maximize2,
- Minimize2,
- Plus,
-} from 'lucide-react';
-import { getOhlcvByTournament } from '../data/mockOhlcv';
-import type { UTCTimestamp, ISeriesApi, IChartApi } from 'lightweight-charts';
-import { CandlestickSeries } from 'lightweight-charts';
-
-/* ----------------------------- Types ----------------------------- */
-
-// A single OHLC candle in Lightweight Charts format
-export interface OhlcCandle {
- time: UTCTimestamp; // seconds since epoch
- open: number;
- high: number;
- low: number;
- close: number;
- // volume is optional in case you add it later
- volume?: number;
-}
+ type CandlestickData,
+ type LineData,
+ type Time,
+} from 'lightweight-charts';
+
+import {
+ mockOhlcvById,
+ mockOhlcv,
+ type OhlcCandle,
+} from '@/app/home/data/mockOhlcv';
+import { useTournamentStore } from '@/src/store/useTournamentStore';
+
+/* ----------------------------------------
+ TYPES
+----------------------------------------- */
+
+type Timeframe = '1m' | '5m' | '15m' | '1h' | '4h' | '1d';
+type IndicatorKey = 'volume' | 'sma' | 'ema' | 'vwap';
interface CandleChartProps {
tournamentId: string;
}
-/* --------------------- Local aggregation utils ------------------ */
+/* ----------------------------------------
+ RESAMPLE BASE 1m DATA
+----------------------------------------- */
-function aggregateOhlc(data: OhlcCandle[], groupSize: number): OhlcCandle[] {
- if (groupSize <= 1) return data;
+function computeOhlcv(data: OhlcCandle[], timeframe: Timeframe): OhlcCandle[] {
+ if (!data.length) return [];
- const result: OhlcCandle[] = [];
+ const interval: Record
= {
+ '1m': 60,
+ '5m': 300,
+ '15m': 900,
+ '1h': 3600,
+ '4h': 14400,
+ '1d': 86400,
+ };
- for (let i = 0; i < data.length; i += groupSize) {
- const group = data.slice(i, i + groupSize);
- if (group.length === 0) continue;
+ const sec = interval[timeframe];
+ if (sec === 60) return data;
- const open = group[0].open;
- const close = group[group.length - 1].close;
- const high = Math.max(...group.map((c) => c.high));
- const low = Math.min(...group.map((c) => c.low));
+ const buckets = new Map();
- result.push({
- time: group[0].time,
- open,
- high,
- low,
- close,
- // If volume exists, sum it:
- volume: group.reduce((acc, c) => acc + (c.volume ?? 0), 0) || undefined,
- });
+ for (const c of data) {
+ const bucket = Math.floor(Number(c.time) / sec) * sec;
+ if (!buckets.has(bucket)) buckets.set(bucket, []);
+ buckets.get(bucket)!.push(c);
}
- return result;
+ return Array.from(buckets.entries())
+ .map(([ts, arr]) => ({
+ time: ts as Time,
+ open: arr[0].open,
+ high: Math.max(...arr.map((x) => x.high)),
+ low: Math.min(...arr.map((x) => x.low)),
+ close: arr[arr.length - 1].close,
+ volume: arr.reduce((s, x) => s + (x.volume || 0), 0),
+ }))
+ .sort((a, b) => Number(a.time) - Number(b.time));
}
-const timeframeMap: Record = {
- '1m': 1, // mock fallback (still 5m resolution)
- '5m': 1,
- '15m': 3,
- '1h': 12,
- '4h': 48,
- '1d': 288,
-};
+/* ----------------------------------------
+ INDICATORS
+----------------------------------------- */
+
+function calculateSMA(data: OhlcCandle[], period = 20): LineData[] {
+ return data.map((c, i) => {
+ if (i < period) return { time: c.time, value: c.close };
+ const slice = data.slice(i - period, i);
+ const avg = slice.reduce((s, x) => s + x.close, 0) / period;
+ return { time: c.time, value: avg };
+ });
+}
-/* --------------------------- Component --------------------------- */
+function calculateEMA(data: OhlcCandle[], period = 20): LineData[] {
+ let prev = data[0].close;
+ const k = 2 / (period + 1);
-export default function CandleChart({ tournamentId }: CandleChartProps) {
- const chartContainerRef = useRef(null);
- const [timeframe, setTimeframe] = useState<'1m' | '5m' | '15m' | '1h' | '4h' | '1d'>('1h');
- const [isFullscreen, setIsFullscreen] = useState(false);
+ return data.map((c) => {
+ const next = c.close * k + prev * (1 - k);
+ prev = next;
+ return { time: c.time, value: next };
+ });
+}
- // raw mock data typed
- const rawData = getOhlcvByTournament(tournamentId) as OhlcCandle[];
- const groupSize = timeframeMap[timeframe] ?? 1;
- const ohlcv = aggregateOhlc(rawData, groupSize);
+function calculateVWAP(data: OhlcCandle[]): LineData[] {
+ let cumulativePV = 0;
+ let cumulativeVol = 0;
+
+ return data.map((c) => {
+ const typical = (c.high + c.low + c.close) / 3;
+ const vol = c.volume || 1;
+ cumulativePV += typical * vol;
+ cumulativeVol += vol;
+ return { time: c.time, value: cumulativePV / cumulativeVol };
+ });
+}
- // ESC exits fullscreen
- useEffect(() => {
- const handleEsc = (e: KeyboardEvent) => {
- if (e.key === 'Escape') setIsFullscreen(false);
- };
- window.addEventListener('keydown', handleEsc);
- return () => window.removeEventListener('keydown', handleEsc);
- }, []);
+/* ----------------------------------------
+ MAIN COMPONENT
+----------------------------------------- */
+export default function CandleChart({ tournamentId }: CandleChartProps) {
+ const chartRef = useRef(null);
+
+ const [timeframe, setTimeframe] = useState('1h');
+ const [fullscreen, setFullscreen] = useState(false);
+ const [toggles, setToggles] = useState>({
+ volume: true,
+ sma: true,
+ ema: true,
+ vwap: true,
+ });
+
+ const storeTournamentId = useTournamentStore(s => s.selectedTournamentId);
+ const storeTournamentName = useTournamentStore(s => s.selectedTournamentName);
+ const finalTournamentId = tournamentId ?? storeTournamentId;
+
+ // Convert string ID to number for mock data lookup
+ const tournamentIdNum = parseInt(finalTournamentId, 10);
+ const baseData = mockOhlcvById[tournamentIdNum] ?? mockOhlcv;
+
+ const ohlcv = useMemo(() => computeOhlcv(baseData, timeframe), [
+ baseData,
+ timeframe,
+ ]);
+
+ /* ----------------------------------------
+ INIT CHART
+ ----------------------------------------- */
useEffect(() => {
- const container = chartContainerRef.current;
- if (!container || ohlcv.length === 0) return;
-
- let chart: IChartApi | null = null;
- let series: ISeriesApi<'Candlestick'> | null = null;
-
- // lightweight-charts dynamically imported only on client
- import('lightweight-charts').then((LightweightCharts) => {
- if (!chartContainerRef.current) return;
+ const container = chartRef.current;
+ if (!container || !ohlcv.length) return;
- const ctn = chartContainerRef.current;
+ container.innerHTML = '';
- // clear old canvases (prevents double charts)
- ctn.innerHTML = '';
+ (async () => {
+ const { createChart } = await import('lightweight-charts');
- chart = LightweightCharts.createChart(ctn, {
- width: ctn.clientWidth,
- height: isFullscreen ? window.innerHeight - 100 : 400,
+ const chart = createChart(container, {
+ width: container.clientWidth,
+ height: fullscreen ? window.innerHeight - 100 : 420,
layout: {
background: { color: 'transparent' },
- textColor: '#9ca3af',
+ textColor: '#ccc',
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.05)' },
horzLines: { color: 'rgba(255,255,255,0.05)' },
},
- timeScale: {
- timeVisible: true,
- borderColor: 'rgba(255,255,255,0.1)' as string,
- },
- rightPriceScale: {
- borderColor: 'rgba(255,255,255,0.1)' as string,
- },
+ timeScale: { timeVisible: true },
});
- series = chart.addSeries(CandlestickSeries, {
- upColor: '#10b981',
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const candleSeries = (chart as any).addCandlestickSeries({
+ upColor: '#22c55e',
downColor: '#ef4444',
- wickUpColor: '#10b981',
+ wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
borderVisible: false,
});
- series.setData(ohlcv);
- chart.timeScale().fitContent();
+ candleSeries.setData(ohlcv as CandlestickData[]);
- const handleResize = () => {
- if (!chart || !chartContainerRef.current) return;
- chart.applyOptions({
- width: chartContainerRef.current.clientWidth,
- height: isFullscreen ? window.innerHeight - 100 : 400,
+ /* ---------------- Volume ---------------- */
+ if (toggles.volume) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const vol = (chart as any).addHistogramSeries({
+ priceScaleId: 'volume',
+ priceFormat: { type: 'volume' },
});
- };
-
- window.addEventListener('resize', handleResize);
-
- // cleanup for this import callback
- return () => {
- window.removeEventListener('resize', handleResize);
- chart?.remove();
- chart = null;
- series = null;
- };
- });
-
- // cleanup for the effect itself (if it re-runs before import resolves)
- return () => {
- chart?.remove();
- chart = null;
- series = null;
- if (container) container.innerHTML = '';
- };
- }, [isFullscreen, timeframe, tournamentId, ohlcv]);
-
- const timeframeButtons: Array<'1m' | '5m' | '15m' | '1h' | '4h' | '1d'> = [
- '1m', '5m', '15m', '1h', '4h', '1d',
- ];
-
- const currentPrice = ohlcv[ohlcv.length - 1]?.close ?? 0;
- const firstPrice = ohlcv[0]?.close ?? currentPrice;
- const change24h =
- firstPrice > 0 ? ((currentPrice - firstPrice) / firstPrice) * 100 : 0;
-
- const volume24h = 1_200_000;
- return (
- ({
+ time: c.time,
+ value: c.volume,
+ color: c.close >= c.open ? '#22c55e' : '#ef4444',
+ }))
+ );
+ }
+
+ /* ---------------- Indicators ---------------- */
+ if (toggles.sma) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (chart as any).addLineSeries({ color: '#60a5fa', lineWidth: 2 }).setData(
+ calculateSMA(ohlcv)
+ );
}
- >
+
+ if (toggles.ema) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (chart as any).addLineSeries({ color: '#fbbf24', lineWidth: 2 }).setData(
+ calculateEMA(ohlcv)
+ );
+ }
+
+ if (toggles.vwap) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (chart as any).addLineSeries({ color: '#a855f7', lineWidth: 2 }).setData(
+ calculateVWAP(ohlcv)
+ );
+ }
+ })();
+ }, [ohlcv, toggles, fullscreen]);
+
+ /* ----------------------------------------
+ PRICE META
+ ----------------------------------------- */
+
+ const last = ohlcv.at(-1);
+ const first = ohlcv[0];
+ const price = last?.close ?? 0;
+ const pct = first ? ((price - first.close) / first.close) * 100 : 0;
+
+ /* ----------------------------------------
+ UI
+ ----------------------------------------- */
+
+ return (
+
- {/* Header */}
-
-
-
-
-
-
-
Portfolio Value
-
Tournament #{tournamentId}
-
-
- ${currentPrice.toFixed(2)}
-
-
= 0 ? 'text-green-400' : 'text-red-400'
- }`}
- >
- {change24h >= 0 ? '↗' : '↘'}
- {Math.abs(change24h).toFixed(2)}%
-
-
-
-
- {/* Timeframe + Fullscreen */}
-
-
- {timeframeButtons.map((tf) => (
-
setTimeframe(tf)}
- className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
- timeframe === tf
- ? 'bg-[#FFD700] text-[#001D3D]'
- : 'text-gray-400 hover:text-white hover:bg-white/10'
- }`}
- >
- {tf}
-
- ))}
+ {/* 🔥 INTERNAL BACKDROP FIX */}
+
+
+ {/* All content above the backdrop */}
+
+
+ {/* HEADER */}
+
+
+
+ {storeTournamentName || 'Tournament'}
+
+
+ ${price.toFixed(2)}
+
+
= 0 ? 'text-green-400' : 'text-red-400'}>
+ {pct.toFixed(2)}%
+
-
setIsFullscreen((v) => !v)}
- className="w-9 h-9 rounded-lg bg-white/5 hover:bg-white/10
- flex items-center justify-center border border-white/10 transition"
- aria-label="Toggle fullscreen"
- >
- {isFullscreen ? (
-
- ) : (
-
+
+ {(['1m', '5m', '15m', '1h', '4h', '1d'] as Timeframe[]).map(
+ (tf) => (
+ setTimeframe(tf)}
+ >
+ {tf}
+
+ )
)}
-
-
-
- {/* Chart */}
-
-
- {/* Footer */}
-
-
-
-
-
Current
-
- ${currentPrice.toFixed(2)}
-
-
-
-
-
24h Change
-
= 0 ? 'text-green-400' : 'text-red-400'
- }`}
- >
- {change24h >= 0 ? '+' : ''}
- {change24h.toFixed(2)}%
-
-
-
-
-
24h Volume
-
- ${(volume24h / 1_000_000).toFixed(2)}M
-
-
+
setFullscreen((v) => !v)}
+ className="p-2 rounded-md bg-white/5 hover:bg-white/10"
+ >
+ {fullscreen ? (
+
+ ) : (
+
+ )}
+
+
-
-
- Add Indicator
-
+ {/* INDICATOR TOGGLES */}
+
+ {(['volume', 'sma', 'ema', 'vwap'] as IndicatorKey[]).map((key) => (
+
+
+ setToggles((prev) => ({
+ ...prev,
+ [key]: !prev[key],
+ }))
+ }
+ />
+ {key.toUpperCase()}
+
+ ))}
+
+ {/* CHART */}
+
diff --git a/frontend/app/home/components/RecentTrades.tsx b/frontend/app/home/components/RecentTrades.tsx
index 23d0372..397c74b 100644
--- a/frontend/app/home/components/RecentTrades.tsx
+++ b/frontend/app/home/components/RecentTrades.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import {
Activity,
@@ -8,17 +9,101 @@ import {
Clock,
Filter,
Download,
+ X,
+ Hash,
+ DollarSign,
+ Calendar,
+ TrendingUp,
+ Coins,
} from 'lucide-react';
import { useTournamentTrades } from '@/src/hooks/useTrades';
+import { useAgents } from '@/src/hooks/useAgents';
+import { useTournament } from '@/src/hooks/useTournaments';
+import { Trade } from '@/src/types';
+import { findAgentById } from '@/src/util/findAgentById';
interface RecentTradesProps {
tournamentId: string;
}
+// Filter options
+type ActionFilter = 'all' | 'buy' | 'sell';
+
export default function RecentTrades({ tournamentId }: RecentTradesProps) {
const { data: trades, isLoading } = useTournamentTrades(tournamentId);
- const filteredTrades = trades || [];
- const isLiveTournament = true; // Assume live for now
+ const { data: agents } = useAgents();
+ const { data: tournament } = useTournament(tournamentId);
+ const [showFilterMenu, setShowFilterMenu] = useState(false);
+ const [actionFilter, setActionFilter] = useState
('all');
+ const [assetFilter, setAssetFilter] = useState('all');
+ const [selectedTrade, setSelectedTrade] = useState(null);
+
+ // Close modal
+ const closeModal = () => {
+ setSelectedTrade(null);
+ };
+
+ // Get unique assets from trades
+ const uniqueAssets = useMemo(() => {
+ if (!trades) return [];
+ const assets = new Set(trades.map((t) => t.asset));
+ return Array.from(assets);
+ }, [trades]);
+
+ // Apply filters
+ const filteredTrades = useMemo(() => {
+ if (!trades) return [];
+ return trades.filter((trade) => {
+ // Action filter
+ if (actionFilter !== 'all' && trade.action.toLowerCase() !== actionFilter) {
+ return false;
+ }
+ // Asset filter
+ if (assetFilter !== 'all' && trade.asset !== assetFilter) {
+ return false;
+ }
+ return true;
+ });
+ }, [trades, actionFilter, assetFilter]);
+
+ // Export to CSV
+ const handleExportCSV = () => {
+ if (filteredTrades.length === 0) return;
+
+ const headers = ['ID', 'Action', 'Asset', 'Amount', 'Price', 'Timestamp', 'Amount USD'];
+ const csvContent = [
+ headers.join(','),
+ ...filteredTrades.map((trade) =>
+ [
+ trade.id,
+ trade.action,
+ trade.asset,
+ trade.amount,
+ trade.price,
+ trade.timestamp,
+ parseFloat(trade.amount) * parseFloat(trade.price),
+ ].join(',')
+ ),
+ ].join('\n');
+
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+ const url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', `trades_tournament_${tournamentId}_${new Date().toISOString().split('T')[0]}.csv`);
+ link.style.visibility = 'hidden';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ // Clear all filters
+ const clearFilters = () => {
+ setActionFilter('all');
+ setAssetFilter('all');
+ };
+
+ const hasActiveFilters = actionFilter !== 'all' || assetFilter !== 'all';
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
@@ -49,60 +134,155 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {
{/* header */}
-
+
Recent Trades
-
Tournament #{tournamentId}
+
{tournament?.name || 'Loading...'}
{/* action buttons */}
-
-
-
+ {/* Filter button */}
+
+
setShowFilterMenu(!showFilterMenu)}
+ >
+
+ {hasActiveFilters && (
+
+ !
+
+ )}
+
+
+ {/* Filter dropdown menu */}
+ {showFilterMenu && (
+
+
+
Filters
+ setShowFilterMenu(false)}
+ className="text-gray-400 hover:text-white transition-colors"
+ >
+
+
+
+
+ {/* Action filter */}
+
+
Actions
+
+ {(['all', 'buy', 'sell'] as ActionFilter[]).map((action) => (
+ setActionFilter(action)}
+ className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium cursor-pointer ${
+ actionFilter === action
+ ? action === 'buy'
+ ? 'bg-green-500/20 text-green-400 border border-green-500/30'
+ : action === 'sell'
+ ? 'bg-red-500/20 text-red-400 border border-red-500/30'
+ : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
+ : 'bg-white/5 text-gray-400 border border-white/10 hover:bg-white/10'
+ }`}
+ >
+ {action.charAt(0).toUpperCase() + action.slice(1)}
+
+ ))}
+
+
+
+ {/* Asset filter */}
+
+
Assets
+ setAssetFilter(e.target.value)}
+ className="w-full bg-[#0a1929] border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-purple-500/50 cursor-pointer appearance-none"
+ >
+ All Assets
+ {uniqueAssets.map((asset) => (
+
+ {asset}
+
+ ))}
+
+
+
+ {/* Clear filters button */}
+ {hasActiveFilters && (
+
+ Clear all filters
+
+ )}
+
+ )}
+
-
0
+ ? 'bg-white/5 hover:bg-white/10 border-white/10 text-gray-400 hover:text-green-400'
+ : 'bg-white/5 border-white/10 text-gray-600 cursor-not-allowed'
+ }`}
+ onClick={handleExportCSV}
+ disabled={filteredTrades.length === 0}
+ title={filteredTrades.length > 0 ? 'Export to CSV' : 'No trades to export'}
>
-
-
+
+
+ {/* Active filters display */}
+ {hasActiveFilters && (
+
+ Active filters:
+ {actionFilter !== 'all' && (
+
+ {actionFilter.toUpperCase()}
+
+ )}
+ {assetFilter !== 'all' && (
+
+ {assetFilter}
+
+ )}
+
+ Clear
+
+
+ )}
+
{/* trades list */}
-
- {filteredTrades.length === 0 ? (
+ {isLoading ? (
+
+ ) : filteredTrades.length === 0 ? (
@@ -129,6 +309,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
+ onClick={() => setSelectedTrade(trade)}
>
{/* glow effect on hover */}
- {/* pulse indicator for recent trades */}
- {index === 0 && isLiveTournament &&(
-
- )}
);
})
@@ -227,7 +391,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {
$
{filteredTrades
.filter((t: Trade) => t.action.toLowerCase() === 'buy')
- .reduce((sum: number, t: Trade) => sum + t.amount_usd, 0)
+ .reduce((sum: number, t: Trade) => sum + (parseFloat(t.amount) * parseFloat(t.price)), 0)
.toLocaleString()}
@@ -244,7 +408,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {
$
{filteredTrades
.filter((t: Trade) => t.action.toLowerCase() === 'sell')
- .reduce((sum: number, t: Trade) => sum + t.amount_usd, 0)
+ .reduce((sum: number, t: Trade) => sum + (parseFloat(t.amount) * parseFloat(t.price)), 0)
.toLocaleString()}
@@ -254,6 +418,141 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {
{/* bottom accent line */}
+
+ {/* Trade Detail Modal */}
+ {selectedTrade && (() => {
+ const isBuy = selectedTrade.action.toLowerCase() === 'buy';
+ const amount = parseFloat(selectedTrade.amount);
+ const price = parseFloat(selectedTrade.price);
+ const totalValue = amount * price;
+ const agent = findAgentById(agents, selectedTrade.agent_id);
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Modal Header */}
+
+
+
+ {isBuy ? (
+
+ ) : (
+
+ )}
+
+
+
+ {selectedTrade.action.toUpperCase()} {selectedTrade.asset}
+
+
+ Trade Details
+
+
+
+
+
+
+
+
+ {/* Modal Content */}
+
+ {/* Total Value */}
+
+
+
+ Total Value
+
+
+ ${totalValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
+
+
+ {/* Stats Grid */}
+
+
+
+
+ Amount
+
+
+ {amount.toLocaleString(undefined, { maximumFractionDigits: 8 })}
+
+
{selectedTrade.asset}
+
+
+
+
+
+ Price
+
+
+ ${price.toLocaleString(undefined, { maximumFractionDigits: 8 })}
+
+
per {selectedTrade.asset}
+
+
+
+ {/* Agent Info */}
+
+
+
+
+
+
+
+
{agent?.name || `Agent ${selectedTrade.agent_id.slice(0, 8)}...`}
+
{agent?.strategy_type || ''}
+
+
+
+
+ {/* Trade ID */}
+
+
+
+ Trade ID
+
+
+ {selectedTrade.id}
+
+
+
+ {/* Timestamp */}
+
+
+
+ Executed: {new Date(selectedTrade.timestamp).toLocaleString()}
+
+
+
+
+
+ );
+ })()}
);
}
diff --git a/frontend/app/home/components/TournamentContainer.tsx b/frontend/app/home/components/TournamentContainer.tsx
new file mode 100644
index 0000000..f3e1899
--- /dev/null
+++ b/frontend/app/home/components/TournamentContainer.tsx
@@ -0,0 +1,5 @@
+'use client';
+
+// Re-export the default TournamentContainer from TournamentStatusBar
+// This component uses the store internally, no props needed
+export { default } from './TournamentStatusBar';
diff --git a/frontend/app/home/components/TournamentStatusBar.tsx b/frontend/app/home/components/TournamentStatusBar.tsx
index 51f2468..3bd2606 100644
--- a/frontend/app/home/components/TournamentStatusBar.tsx
+++ b/frontend/app/home/components/TournamentStatusBar.tsx
@@ -1,52 +1,74 @@
'use client';
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useTournaments } from '@/src/hooks/useTournaments';
+
import AgentPositions from './AgentPositions';
import ActiveBets from './ActiveBets';
-import { useTournaments, useTournament } from '@/src/hooks/useTournaments';
-interface TournamentContainerProps {
- selectedTournamentId: string;
- onTournamentChange: (id: string) => void;
-}
+import { useTournamentStore } from '@/src/store/useTournamentStore';
+import type { TournamentStatus } from '@/src/store/useTournamentStore';
-function TournamentStatusBar({
- tournament,
- onTournamentChange,
- allTournaments,
-}: {
- tournament: any;
- onTournamentChange: (tournamentId: string) => void;
- allTournaments: any[];
-}) {
+/* ---------------------------------------------------------
+ TOURNAMENT STATUS BAR (Named Export)
+--------------------------------------------------------- */
+export function TournamentStatusBar() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- if (!tournament) {
- return (
-
-
Loading tournament...
-
- );
- }
-
- // Format the end time nicely
- const formattedEndTime = new Date(tournament.end_date).toLocaleDateString(
- 'en-US',
- {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- }
+ const selectedTournamentId = useTournamentStore((s) => s.selectedTournamentId);
+ const setTournamentId = useTournamentStore((s) => s.setTournamentId);
+ const setTournamentStatus = useTournamentStore((s) => s.setTournamentStatus);
+ const { data: tournaments } = useTournaments();
+
+ const normalizeStatus = (status?: string): TournamentStatus => {
+ const normalized = (status || '').toLowerCase();
+ if (normalized === 'live') return 'LIVE';
+ if (normalized === 'completed' || normalized === 'ended') return 'ENDED';
+ if (normalized === 'upcoming') return 'UPCOMING';
+ return 'UPCOMING';
+ };
+
+ const backendTournament = tournaments?.find(
+ (t) => String(t.id) === String(selectedTournamentId)
);
- // Get status badge color
+ const uiTournament = backendTournament
+ ? {
+ id: String(backendTournament.id),
+ name: backendTournament.name,
+ status: normalizeStatus(backendTournament.status),
+ prize_pool_usd: Number(backendTournament.prize_pool),
+ end_time: backendTournament.end_date,
+ }
+ : undefined;
+
+ useEffect(() => {
+ if (uiTournament?.status) {
+ setTournamentStatus(uiTournament.status);
+ }
+ }, [uiTournament?.status, setTournamentStatus]);
+
+ useEffect(() => {
+ if (tournaments && tournaments.length > 0 && !selectedTournamentId) {
+ setTournamentId(String(tournaments[0].id), tournaments[0].name);
+ }
+ }, [tournaments, selectedTournamentId, setTournamentId]);
+
+ const formattedEndTime = uiTournament?.end_time
+ ? new Date(uiTournament.end_time).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })
+ : 'TBD';
+
const getStatusColor = (status: string) => {
- switch (status.toLowerCase()) {
- case 'live':
+ switch (status) {
+ case 'LIVE':
return 'bg-green-500/20 text-green-400';
- case 'completed':
+ case 'ENDED':
return 'bg-gray-500/20 text-gray-400';
- case 'upcoming':
+ case 'UPCOMING':
return 'bg-blue-500/20 text-blue-400';
default:
return 'bg-gray-500/20 text-gray-400';
@@ -55,119 +77,119 @@ function TournamentStatusBar({
return (
+ {/* Left Section: Title + Dropdown */}
-
TOURNAMENT
+
+ {uiTournament?.name || 'TOURNAMENT'}
+
- {/* Dropdown Button with Triangle */}
+ {/* Dropdown */}
setIsDropdownOpen(!isDropdownOpen)}
className="w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center hover:bg-yellow-300 transition-colors"
>
-
+
- {/* Dropdown Menu */}
{isDropdownOpen && (
- {allTournaments.map((t) => (
+ {(tournaments ?? []).map((t) => {
+ const status = normalizeStatus(t.status);
+ const prizePool = Number(t.prize_pool);
+ const endTime = t.end_date;
+ const idStr = String(t.id);
+ const active = uiTournament ? idStr === String(uiTournament.id) : false;
+ return (
{
- onTournamentChange(t.id);
+ setTournamentId(idStr, t.name);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-3 hover:bg-slate-700 transition-colors ${
- t.id === tournament.id ? 'bg-slate-700/50' : ''
+ active ? 'bg-slate-700/50' : ''
}`}
>
-
- {t.name}
-
+
{t.name || `Tournament ${idStr.slice(0, 8)}`}
- ${parseFloat(t.prize_pool).toLocaleString()} •{' '}
- {new Date(t.end_date).toLocaleDateString('en-US', {
+ ${Number(prizePool).toLocaleString()} *{' '}
+ {endTime ? new Date(endTime).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
- })}
+ }) : 'TBD'}
+
- {t.status}
+ {status}
- ))}
+ );
+ })}
)}
+ {/* Right Section: Status Info */}
Status:{' '}
- {tournament.status}
+ {uiTournament?.status ?? "UPCOMING"}
+
Prize Pool:{' '}
- ${parseFloat(tournament.prize_pool).toLocaleString()}
+ {uiTournament ? `$${Number(uiTournament.prize_pool_usd).toLocaleString()}` : "—"}
+
- Ends: {formattedEndTime}
+ Ends:{' '}
+ {formattedEndTime}
);
}
-export default function TournamentContainer({
- selectedTournamentId,
- onTournamentChange,
-}: TournamentContainerProps) {
- const { data: tournaments, isLoading } = useTournaments();
- const { data: selectedTournament } = useTournament(selectedTournamentId);
-
- if (isLoading) {
- return (
-
-
-
Loading tournaments...
-
-
- );
- }
+/* ---------------------------------------------------------
+ TOURNAMENT CONTAINER (Default Export)
+--------------------------------------------------------- */
+export default function TournamentContainer() {
+ const selectedTournamentId = useTournamentStore((s) => s.selectedTournamentId);
return (
- {/* Tournament Status Bar - Header */}
-
-
- {/* Agent Positions and Active Bets - Content Area */}
+ {/* Header */}
+
+
+ {/* Main Content Grid */}
+ {/* Left: Agent holdings */}
-
+
+ {/* Right: Bets (active + my bets) */}
+
);
diff --git a/frontend/app/home/components/UserInfo.tsx b/frontend/app/home/components/UserInfo.tsx
deleted file mode 100644
index 1a92d41..0000000
--- a/frontend/app/home/components/UserInfo.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-'use client';
-
-import React, { useState } from 'react';
-import { UserProfile, mockUser } from '../data/mockUser';
-
-interface UserProfileCardProps {
- user?: UserProfile;
- onEditProfile?: () => void;
- onViewProfile?: () => void;
- onDisconnectWallet?: () => void;
-}
-
-export const UserProfileCard: React.FC
= ({
- user = mockUser,
- onEditProfile,
- onViewProfile,
- onDisconnectWallet,
-}) => {
- const [isMenuOpen, setIsMenuOpen] = useState(false);
- const [isEditing, setIsEditing] = useState(false);
- const [currentUser, setCurrentUser] = useState(user);
- const [editedUser, setEditedUser] = useState(user);
-
- const getInitials = (name: string) => {
- return name
- .split(' ')
- .map((word) => word[0])
- .join('')
- .toUpperCase()
- .slice(0, 2);
- };
-
- const handleSaveChanges = () => {
- setCurrentUser(editedUser);
- setIsEditing(false);
- if (onEditProfile) onEditProfile();
- };
-
- const handleCancelEdit = () => {
- setEditedUser(currentUser);
- setIsEditing(false);
- };
-
- const handleInputChange = (field: keyof UserProfile, value: string) => {
- setEditedUser((prev) => ({
- ...prev,
- [field]: value,
- }));
- };
-
- const handleSocialMediaChange = (
- platform: 'twitter' | 'email',
- value: string
- ) => {
- setEditedUser((prev) => ({
- ...prev,
- socialMedia: {
- ...prev.socialMedia,
- [platform]: value,
- },
- }));
- };
-
- return (
-
-
-
- Edit Profile
-
-
- {/* Standardized spacing */}
-
- {/* NAME */}
-
-
- Name
-
- handleInputChange('name', e.target.value)}
- className="w-full px-4 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white
- focus:outline-none focus:border-yellow-400"
- placeholder="Enter your name"
- />
-
-
- {/* pronouns */}
-
-
- Pronouns (Optional)
-
- handleInputChange('pronouns', e.target.value)}
- className="w-full px-4 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white
- focus:outline-none focus:border-yellow-400"
- placeholder="e.g., he/him, she/her, they/them"
- />
-
-
- {/* wallet id */}
-
-
- Wallet ID
-
-
-
-
- {/* socials*/}
-
-
- {/* buttons */}
-
-
- Save Changes
-
-
- Cancel
-
-
-
-
-
- {/* view mode */}
-
- {/* Menu */}
-
-
setIsMenuOpen(!isMenuOpen)}
- className="text-yellow-400 hover:text-yellow-300"
- >
-
-
-
-
-
-
-
- {isMenuOpen && (
-
-
- {onViewProfile && (
-
{
- onViewProfile();
- setIsMenuOpen(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-white hover:bg-slate-700"
- >
- View Full Profile
-
- )}
-
{
- setIsEditing(true);
- setIsMenuOpen(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-white hover:bg-slate-700"
- >
- Edit Profile
-
-
{
- navigator.clipboard.writeText(currentUser.walletId);
- setIsMenuOpen(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-white hover:bg-slate-700"
- >
- Copy Wallet ID
-
- {onDisconnectWallet && (
- <>
-
-
{
- onDisconnectWallet();
- setIsMenuOpen(false);
- }}
- className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-slate-700"
- >
- Disconnect Wallet
-
- >
- )}
-
-
- )}
-
-
- {/* Profile section */}
-
-
- {currentUser.profilePicUrl ? (
-
- ) : (
- getInitials(currentUser.name)
- )}
-
-
-
-
- {currentUser.name}
-
-
- {currentUser.pronouns && (
-
({currentUser.pronouns})
- )}
-
-
-
-
-
- WALLET ID: {currentUser.walletId}
-
-
-
- {currentUser.socialMedia && (
-
- {currentUser.socialMedia.twitter && (
-
-
-
-
-
- )}
-
- {currentUser.socialMedia.email && (
-
-
-
-
-
- )}
-
- )}
-
-
- );
-};
-
-export default UserProfileCard;
diff --git a/frontend/app/home/dashboard/AgentCard.tsx b/frontend/app/home/dashboard/AgentCard.tsx
new file mode 100644
index 0000000..490d0bb
--- /dev/null
+++ b/frontend/app/home/dashboard/AgentCard.tsx
@@ -0,0 +1,211 @@
+'use client';
+
+import React, { useState, useEffect } from "react";
+import { ChevronDown, ChevronUp } from "lucide-react";
+
+import { useTournamentStore } from "@/src/store/useTournamentStore";
+import { useTournament } from "@/src/hooks/useTournaments";
+import Tooltip from "@/app/components/ui/Tooltip";
+import { BetButton } from "@/src/betting/betButton";
+
+import type { Agent } from "@/src/types/Agent";
+import type { AgentBet } from "@/src/types/AgentBet";
+
+/* ------------------------------------------------------
+ NORMALIZER — Convert backend Agent → AgentBet
+------------------------------------------------------- */
+function normalizeAgent(agent: Agent): AgentBet {
+ const selectedTournamentId =
+ useTournamentStore.getState().selectedTournamentId;
+
+ return {
+ id: agent.id,
+ name: agent.name,
+ personality: agent.personality,
+ tournamentId: String(selectedTournamentId),
+
+ winRate: agent.win_rate * 100,
+ portfolioValue: agent.total_value,
+ pnl: agent.total_value - 10000,
+ volatility: agent.risk_score * 100,
+ odds: Number((1 + agent.risk_score).toFixed(2)),
+ };
+}
+
+/* ------------------------------------------------------
+ COMPONENT PROPS
+------------------------------------------------------- */
+interface AgentCardProps {
+ agent: Agent;
+ rank: number; // <-- NEW: pass rank from parent list
+ totalAgents: number;
+ forceExpand?: boolean;
+}
+
+export default function AgentCard({
+ agent,
+ rank,
+ forceExpand,
+}: AgentCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const selectedTournamentId = useTournamentStore(
+ (s) => s.selectedTournamentId
+ );
+ const { data: tournament } = useTournament(selectedTournamentId);
+
+ useEffect(() => {
+ if (forceExpand !== undefined) setExpanded(forceExpand); // eslint-disable-line react-hooks/set-state-in-effect
+ }, [forceExpand]);
+
+ const modalAgent = normalizeAgent(agent);
+ const contractTournamentId = tournament?.contract_tournament_id ?? null;
+ const contractAgentId =
+ tournament?.agent_contract_mapping?.[agent.id] ?? null;
+
+ const winRatePct = modalAgent.winRate.toFixed(1);
+ const roiPct = agent.roi_percent.toFixed(2);
+
+ return (
+ setExpanded(!expanded)}
+ >
+ {/* ---------- HEADER ---------- */}
+
+
+ {/* LEFT SIDE: Rank / Name / Portfolio */}
+
+ {/* Rank Badge */}
+
+
+ Rank #{rank}
+
+
+
+
{agent.name}
+
+
+ Portfolio: ${modalAgent.portfolioValue.toLocaleString()}
+
+
+
+ Win rate: {winRatePct}%
+
+
+
+ {/* RIGHT SIDE: Odds / Bet Button / Expand */}
+
+ {/* Odds */}
+
+
+ {modalAgent.odds}x
+
+ Payout multiplier if this agent wins.}>
+ ⓘ
+
+
+
+ {/* Bet Button */}
+
+
+ {/* Expand / Collapse */}
+
{
+ e.stopPropagation();
+ setExpanded(!expanded);
+ }}
+ >
+ {expanded ? (
+ <>
+ Collapse
+ >
+ ) : (
+ <>
+ Expand
+ >
+ )}
+
+
+
+
+ {/* ---------- EXPANDED SECTION ---------- */}
+ {expanded && (
+
+
+ {/* Personality */}
+
+ Personality: {" "}
+ {agent.personality}
+
+
+ {/* GRID METRICS */}
+
+
+ {/* ROI */}
+
+ ROI: {roiPct}%
+ Return relative to starting balance.}>
+ ⓘ
+
+
+
+ {/* P&L */}
+
+ P&L: $
+ {modalAgent.pnl.toLocaleString()}
+ Profit or loss from the $10,000 baseline.}>
+ ⓘ
+
+
+
+ {/* Trades */}
+
+ Trades: {" "}
+ {agent.num_trades}
+ Total number of executed trades.}>
+ ⓘ
+
+
+
+ {/* Volatility */}
+
+ Volatility: {" "}
+ {modalAgent.volatility.toFixed(1)}%
+ Higher volatility means larger swings.}>
+ ⓘ
+
+
+
+ {/* NEW: Win Rate in expanded grid for consistency */}
+
+ Win Rate: {" "}
+ {winRatePct}%
+ Percentage of profitable trades.}>
+ ⓘ
+
+
+
+ {/* NEW: Odds inside expanded grid also (optional duplicate display) */}
+
+ Odds: {" "}
+ {modalAgent.odds}x
+ Payout multiplier if this agent wins.}>
+ ⓘ
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/app/home/dashboard/AgentListSection.tsx b/frontend/app/home/dashboard/AgentListSection.tsx
new file mode 100644
index 0000000..90f1ad8
--- /dev/null
+++ b/frontend/app/home/dashboard/AgentListSection.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import React, { useState, useMemo } from 'react';
+import AgentCard from './AgentCard';
+import { mockAgents } from '../data/mockAgents';
+
+// Zustand
+import { useTournamentStore } from '@/src/store/useTournamentStore';
+
+// -----------------------------------------
+// 1. Normalize backend -> frontend fields
+// -----------------------------------------
+function normalizeAgents() {
+ return mockAgents.map((a, index) => ({
+ ...a,
+
+ // Backend snake_case → camelCase
+ winRate: a.win_rate,
+ roiPercent: a.roi_percent,
+ riskScore: a.risk_score,
+ holdingsValue: a.holdings_value,
+ totalValue: a.total_value,
+ numTrades: a.num_trades,
+
+ // Defaults for fields the UI expects
+ tournamentId: a.tournament_id, // now comes from mock data
+ rank: index + 1, // simple placeholder ranking
+ odds: Number((1 + Math.random()).toFixed(2)), // mock odds
+ }));
+}
+
+export default function AgentListSection() {
+ const [forceExpandAll, setForceExpandAll] = useState(undefined);
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const [openId, setOpenId] = useState(null);
+ const [sortBy, setSortBy] = useState<'rank' | 'winRate' | 'odds'>('rank');
+ const [search, setSearch] = useState('');
+
+ // Zustand tournament ID
+ const selectedTournamentId = Number(
+ useTournamentStore((s) => s.selectedTournamentId)
+ );
+
+ // Normalize backend agents once
+ const agents = useMemo(() => normalizeAgents(), []);
+
+ // ----------------------------------------------------
+ // FILTER + SEARCH + SORT PROCESSING PIPELINE
+ // ----------------------------------------------------
+ const processedAgents = useMemo(() => {
+ let list = agents.filter(
+ (a) => Number(a.tournamentId) === selectedTournamentId
+ );
+
+ // Search filter
+ if (search.trim() !== '') {
+ list = list.filter((a) =>
+ a.name.toLowerCase().includes(search.toLowerCase())
+ );
+ }
+
+ // Sorting logic
+ list.sort((a, b) => {
+ let result = 0;
+
+ if (sortBy === 'rank') result = (a.rank ?? 999) - (b.rank ?? 999);
+ if (sortBy === 'winRate') result = b.winRate - a.winRate;
+ if (sortBy === 'odds') result = a.odds - b.odds;
+
+ return sortDirection === 'asc' ? result : -result;
+ });
+
+ return list;
+ }, [agents, sortBy, sortDirection, search, selectedTournamentId]);
+
+ const totalAgentsShown = processedAgents.length;
+
+ // Accordion toggle
+ const handleAccordionToggle = (id: string) => {
+ setForceExpandAll(undefined);
+ setOpenId((prev) => (prev === id ? null : id));
+ };
+
+ // ------------------------------------------
+ // RENDER
+ // ------------------------------------------
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Agents in This Tournament
+
+
+
+ Total Agents: {agents.filter(a =>
+ Number(a.tournamentId) === selectedTournamentId
+ ).length}
+ {totalAgentsShown !== agents.length && (
+ <> • Showing: {totalAgentsShown}>
+ )}
+
+
+
+ {/* Controls */}
+
+ {
+ setForceExpandAll(true);
+ setOpenId(null);
+ }}
+ className="px-3 py-2 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded-lg"
+ >
+ Expand All
+
+
+ {
+ setForceExpandAll(false);
+ setOpenId(null);
+ }}
+ className="px-3 py-2 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded-lg"
+ >
+ Collapse All
+
+
+ {/* Sort Mode */}
+ setSortBy(e.target.value as 'rank' | 'winRate' | 'odds')}
+ className="px-3 py-2 text-sm bg-slate-800 text-white rounded-lg"
+ >
+ Sort: Rank
+ Sort: Win Rate
+ Sort: Odds
+
+
+ {/* Sort Direction */}
+ setSortDirection(e.target.value as 'asc' | 'desc')}
+ className="px-3 py-2 text-sm bg-slate-800 text-white rounded-lg"
+ >
+ Asc ↑
+ Desc ↓
+
+
+ {/* Search */}
+ setSearch(e.target.value)}
+ className="px-3 py-2 text-sm bg-slate-900 text-white rounded-lg border border-white/10 w-80"
+ />
+
+
+
+ {/* Agent List */}
+
+ {processedAgents.map((agent) => {
+ const forceExpandProp: boolean | undefined =
+ forceExpandAll !== undefined
+ ? forceExpandAll
+ : openId === agent.id;
+
+ return (
+
handleAccordionToggle(agent.id)}
+ className="cursor-pointer"
+ >
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/app/home/data/generateOhlcv.js b/frontend/app/home/data/generateOhlcv.js
new file mode 100644
index 0000000..a48a2f9
--- /dev/null
+++ b/frontend/app/home/data/generateOhlcv.js
@@ -0,0 +1,54 @@
+// generateOhlcv.js
+// Run with: node generateOhlcv.js
+/* eslint-disable @typescript-eslint/no-require-imports */
+
+const fs = require("fs");
+
+function generateOhlcv(count = 500) {
+ let price = 115 + Math.random() * 5; // start in mid-range
+
+ const data = [];
+ const now = Date.now();
+ const candleMs = 60_000; // 1m candles
+
+ for (let i = count - 1; i >= 0; i--) {
+ const ts = Math.floor((now - i * candleMs) / 1000);
+
+ // Base random walk
+ const drift = (Math.random() - 0.5) * 1.5;
+ const volatility = (Math.random() - 0.5) * 2.0;
+ price += drift + volatility;
+
+ // Clamp between 100–130
+ price = Math.min(130, Math.max(100, price));
+
+ const open = price + (Math.random() - 0.5) * 1.2;
+ const close = price + (Math.random() - 0.5) * 1.2;
+
+ const high = Math.max(open, close) + Math.random() * 1.5;
+ const low = Math.min(open, close) - Math.random() * 1.5;
+
+ const volume = Math.floor(120 + Math.random() * 300);
+
+ data.push({
+ time: ts,
+ open: parseFloat(open.toFixed(2)),
+ high: parseFloat(high.toFixed(2)),
+ low: parseFloat(low.toFixed(2)),
+ close: parseFloat(close.toFixed(2)),
+ volume,
+ });
+ }
+
+ return data;
+}
+
+const candles = generateOhlcv(500);
+
+// 🔥 WRITE DIRECTLY INTO NEXT.JS DATA FOLDER
+fs.writeFileSync(
+ "./frontend/app/home/data/mockOhlcv.ts",
+ "export const mockOhlcv = " + JSON.stringify(candles, null, 2) + ";\n"
+);
+
+console.log("Generated frontend/app/home/data/mockOhlcv.ts with 500 candles!");
diff --git a/frontend/app/home/data/mockAgents.ts b/frontend/app/home/data/mockAgents.ts
new file mode 100644
index 0000000..6e3cb74
--- /dev/null
+++ b/frontend/app/home/data/mockAgents.ts
@@ -0,0 +1,235 @@
+export const mockAgents = [
+ {
+ id: "1",
+ tournamentId: 5,
+ tournament_id: 5,
+
+ name: "QuantPanda",
+ type: "MomentumTrader",
+ personality: "aggressive",
+ risk_score: 0.82,
+ total_value: 10523.40,
+ cash: 234.80,
+ holdings_value: 10288.60,
+ roi_percent: 12.4,
+ num_trades: 152,
+ win_rate: 0.61,
+
+ // Betting fields
+ odds: 2.4,
+ oddsDecimal: 2.4,
+ oddsFractional: "7/5",
+ minBetEth: "0.001",
+
+ rank: 1,
+ volatility: 0.75,
+ quote: "Staying nimble in fast markets!",
+ },
+
+ {
+ id: "2",
+ tournamentId: 5,
+ tournament_id: 5,
+
+ name: "MeanReverter",
+ type: "StatArb",
+ personality: "neutral",
+ risk_score: 0.45,
+ total_value: 9880.10,
+ cash: 500.00,
+ holdings_value: 9380.10,
+ roi_percent: -1.2,
+ num_trades: 98,
+ win_rate: 0.49,
+
+ odds: 1.8,
+ oddsDecimal: 1.8,
+ oddsFractional: "4/5",
+ minBetEth: "0.001",
+
+ rank: 2,
+ volatility: 0.32,
+ quote: "Everything returns to the mean.",
+ },
+
+ {
+ id: "3",
+ tournamentId: 5,
+ tournament_id: 5,
+
+ name: "DeepHODL",
+ type: "NeuralNetTrader",
+ personality: "patient",
+ risk_score: 0.32,
+ total_value: 11256.75,
+ cash: 1056.10,
+ holdings_value: 10200.65,
+ roi_percent: 18.9,
+ num_trades: 65,
+ win_rate: 0.72,
+
+ odds: 3.1,
+ oddsDecimal: 3.1,
+ oddsFractional: "21/10",
+ minBetEth: "0.001",
+
+ rank: 3,
+ volatility: 0.18,
+ quote: "Learning as I go.",
+ },
+
+ {
+ id: "4",
+ tournamentId: 5,
+ tournament_id: 5,
+
+ name: "FlashBear",
+ type: "HighFreqTrader",
+ personality: "volatile",
+ risk_score: 0.91,
+ total_value: 9204.33,
+ cash: 420.00,
+ holdings_value: 8784.33,
+ roi_percent: -5.7,
+ num_trades: 302,
+ win_rate: 0.44,
+
+ odds: 4.0,
+ oddsDecimal: 4.0,
+ oddsFractional: "3/1",
+ minBetEth: "0.001",
+
+ rank: 4,
+ volatility: 0.95,
+ quote: "Fast trades, fast gains—or losses.",
+ },
+
+ {
+ id: "5",
+ tournamentId: 5,
+ tournament_id: 5,
+
+ name: "EcoInvestor",
+ type: "MacroFund",
+ personality: "conservative",
+ risk_score: 0.21,
+ total_value: 10012.90,
+ cash: 2500.00,
+ holdings_value: 7512.90,
+ roi_percent: 3.8,
+ num_trades: 40,
+ win_rate: 0.57,
+
+ odds: 2.0,
+ oddsDecimal: 2.0,
+ oddsFractional: "1/1",
+ minBetEth: "0.001",
+
+ rank: 5,
+ volatility: 0.12,
+ quote: "Slow and steady wins.",
+ },
+
+ // --- T O U R N A M E N T # 4 (ENDED) ---
+
+ {
+ id: "10",
+ tournamentId: 4,
+ tournament_id: 4,
+ name: "AlphaWolf",
+ type: "Momentum",
+ personality: "aggressive",
+ risk_score: 0.74,
+ total_value: 12540.44,
+ cash: 300.12,
+ holdings_value: 12240.32,
+ roi_percent: 15.1,
+ num_trades: 220,
+ win_rate: 0.68,
+
+ odds: 1.6,
+ oddsDecimal: 1.6,
+ oddsFractional: "3/5",
+ minBetEth: "0.001",
+
+ rank: 1, // WINNER
+ volatility: 0.65,
+ quote: "Strike fast, strike smart.",
+ },
+
+ {
+ id: "11",
+ tournamentId: 4,
+ tournament_id: 4,
+ name: "MeanBear",
+ type: "Reversion",
+ personality: "neutral",
+ risk_score: 0.40,
+ total_value: 11980.33,
+ cash: 512.12,
+ holdings_value: 11468.21,
+ roi_percent: 10.8,
+ num_trades: 140,
+ win_rate: 0.55,
+
+ odds: 2.2,
+ oddsDecimal: 2.2,
+ oddsFractional: "6/5",
+ minBetEth: "0.001",
+
+ rank: 2,
+ volatility: 0.28,
+ quote: "Everything swings back eventually.",
+ },
+
+ {
+ id: "12",
+ tournamentId: 4,
+ tournament_id: 4,
+ name: "NeuroBull",
+ type: "NeuralTrader",
+ personality: "patient",
+ risk_score: 0.35,
+ total_value: 10800.00,
+ cash: 760,
+ holdings_value: 10040,
+ roi_percent: 4.8,
+ num_trades: 100,
+ win_rate: 0.51,
+
+ odds: 3.0,
+ oddsDecimal: 3.0,
+ oddsFractional: "2/1",
+ minBetEth: "0.001",
+
+ rank: 3,
+ volatility: 0.22,
+ quote: "Learning every tick.",
+ },
+
+ {
+ id: "13",
+ tournamentId: 4,
+ tournament_id: 4,
+ name: "HedgeHawk",
+ type: "Macro",
+ personality: "conservative",
+ risk_score: 0.22,
+ total_value: 9440.59,
+ cash: 2400.22,
+ holdings_value: 7040.37,
+ roi_percent: -2.3,
+ num_trades: 42,
+ win_rate: 0.47,
+
+ odds: 4.8,
+ oddsDecimal: 4.8,
+ oddsFractional: "19/5",
+ minBetEth: "0.001",
+
+ rank: 4,
+ volatility: 0.18,
+ quote: "Slow is smooth. Smooth is fast.",
+ }
+];
+
diff --git a/frontend/app/home/data/mockBet.ts b/frontend/app/home/data/mockBet.ts
index ccf3e0b..8cf89f5 100644
--- a/frontend/app/home/data/mockBet.ts
+++ b/frontend/app/home/data/mockBet.ts
@@ -1,14 +1,24 @@
-export const mockBets = [
- //sample tournament 5 - live
- { id: 1, agent: "Diamond Hands Dan", amount_eth: 0.05, tournament_id: 5, status: "active" },
- { id: 2, agent: "YOLO Trader", amount_eth: 0.15, tournament_id: 5, status: "active" },
- { id: 3, agent: "Sniper Bot", amount_eth: 0.08, tournament_id: 5, status: "active" },
- { id: 4, agent: "Paper Hands Pete", amount_eth: 0.03, tournament_id: 5, status: "active" },
-
+interface MockBet {
+ id: number;
+ agent: string;
+ amount_eth: number;
+ tournament_id: number;
+ status: string;
+ direction?: 'up' | 'down';
+ payout?: number;
+}
+
+export const mockBets: MockBet[] = [
+ //sample tournament 5 - live
+ { id: 1, agent: "Diamond Hands Dan", amount_eth: 0.05, tournament_id: 5, status: "active", direction: "up" },
+ { id: 2, agent: "YOLO Trader", amount_eth: 0.15, tournament_id: 5, status: "active", direction: "up" },
+ { id: 3, agent: "Sniper Bot", amount_eth: 0.08, tournament_id: 5, status: "active", direction: "down" },
+ { id: 4, agent: "Paper Hands Pete", amount_eth: 0.03, tournament_id: 5, status: "active", direction: "up" },
+
// sample tournament 4 - Ended
- { id: 5, agent: "Safe Mode Sarah", amount_eth: 0.1, tournament_id: 4, status: "won" },
- { id: 6, agent: "Diamond Hands Dan", amount_eth: 0.07, tournament_id: 4, status: "lost" },
- { id: 7, agent: "YOLO Trader", amount_eth: 0.2, tournament_id: 4, status: "won" },
- { id: 8, agent: "Sniper Bot", amount_eth: 0.12, tournament_id: 4, status: "lost" },
- { id: 9, agent: "Paper Hands Pete", amount_eth: 0.04, tournament_id: 4, status: "lost" },
+ { id: 5, agent: "Safe Mode Sarah", amount_eth: 0.1, tournament_id: 4, status: "won", direction: "up", payout: 0.2 },
+ { id: 6, agent: "Diamond Hands Dan", amount_eth: 0.07, tournament_id: 4, status: "lost", direction: "down" },
+ { id: 7, agent: "YOLO Trader", amount_eth: 0.2, tournament_id: 4, status: "won", direction: "up", payout: 0.4 },
+ { id: 8, agent: "Sniper Bot", amount_eth: 0.12, tournament_id: 4, status: "lost", direction: "up" },
+ { id: 9, agent: "Paper Hands Pete", amount_eth: 0.04, tournament_id: 4, status: "lost", direction: "down" },
];
\ No newline at end of file
diff --git a/frontend/app/home/data/mockBets.ts b/frontend/app/home/data/mockBets.ts
new file mode 100644
index 0000000..672754d
--- /dev/null
+++ b/frontend/app/home/data/mockBets.ts
@@ -0,0 +1,92 @@
+import type { Bet } from "@/src/types/bets";
+
+export const mockBets: Bet[] = [
+ {
+ id: "101",
+ tournament_id: "5",
+ user_address: "0x123",
+ agent_id: "1",
+ amount: 0.25,
+ odds: 1.5,
+ placed_at: "2025-02-01T10:00:00Z",
+ settled: false,
+ payout: null,
+ },
+ {
+ id: "102",
+ tournament_id: "5",
+ user_address: "0x123",
+ agent_id: "2",
+ amount: 0.15,
+ odds: 2.0,
+ placed_at: "2025-02-01T10:05:00Z",
+ settled: false,
+ payout: null,
+ },
+ {
+ id: "103",
+ tournament_id: "5",
+ user_address: "0x123",
+ agent_id: "3",
+ amount: 0.32,
+ odds: 1.8,
+ placed_at: "2025-02-01T10:10:00Z",
+ settled: false,
+ payout: null,
+ },
+ {
+ id: "104",
+ tournament_id: "5",
+ user_address: "0x123",
+ agent_id: "1",
+ amount: 0.20,
+ odds: 2.0,
+ placed_at: "2025-01-28T14:00:00Z",
+ settled: true,
+ payout: 0.4,
+ },
+ {
+ id: "105",
+ tournament_id: "5",
+ user_address: "0x123",
+ agent_id: "2",
+ amount: 0.18,
+ odds: 1.5,
+ placed_at: "2025-01-29T09:30:00Z",
+ settled: true,
+ payout: 0,
+ },
+ {
+ id: "201",
+ tournament_id: "4",
+ user_address: "0x123",
+ agent_id: "10",
+ amount: 0.20,
+ odds: 2.25,
+ placed_at: "2025-01-10T08:00:00Z",
+ settled: true,
+ payout: 0.45,
+ },
+ {
+ id: "202",
+ tournament_id: "4",
+ user_address: "0x123",
+ agent_id: "12",
+ amount: 0.1,
+ odds: 1.8,
+ placed_at: "2025-01-11T12:30:00Z",
+ settled: true,
+ payout: 0.18,
+ },
+ {
+ id: "203",
+ tournament_id: "4",
+ user_address: "0x123",
+ agent_id: "13",
+ amount: 0.3,
+ odds: 1.5,
+ placed_at: "2025-01-12T15:45:00Z",
+ settled: true,
+ payout: 0,
+ },
+];
diff --git a/frontend/app/home/data/mockOhlcv.ts b/frontend/app/home/data/mockOhlcv.ts
index aed6c58..bd660da 100644
--- a/frontend/app/home/data/mockOhlcv.ts
+++ b/frontend/app/home/data/mockOhlcv.ts
@@ -1,4 +1,14 @@
-import { UTCTimestamp } from "lightweight-charts";
+import { UTCTimestamp, Time } from "lightweight-charts";
+
+// Type for OHLC candle data
+export interface OhlcCandle {
+ time: UTCTimestamp | Time;
+ open: number;
+ high: number;
+ low: number;
+ close: number;
+ volume?: number;
+}
// Tournament 5 - LIVE (higher prices, trending up)
export const mockOhlcv5 = [
@@ -88,3 +98,11 @@ export function getOhlcvByTournament(tournamentId: number) {
// Default export for backwards compatibility
export const mockOhlcv = mockOhlcv5;
+
+// Indexed object for ID-based lookup
+export const mockOhlcvById: Record = {
+ 1: mockOhlcv5,
+ 4: mockOhlcv4,
+ 5: mockOhlcv5,
+ 6: mockOhlcv6,
+};
diff --git a/frontend/app/home/data/mockTradeCandle.ts b/frontend/app/home/data/mockTradeCandle.ts
new file mode 100644
index 0000000..0439396
--- /dev/null
+++ b/frontend/app/home/data/mockTradeCandle.ts
@@ -0,0 +1,54 @@
+// generateOhlcv.js
+// Run with: node generateOhlcv.js
+/* eslint-disable @typescript-eslint/no-require-imports */
+
+const fs = require("fs");
+
+function generateOhlcv(count = 500) {
+ let price = 115 + Math.random() * 5; // start in mid-range
+
+ const data = [];
+ const now = Date.now();
+ const candleMs = 60_000; // 1m candles
+
+ for (let i = count - 1; i >= 0; i--) {
+ const ts = Math.floor((now - i * candleMs) / 1000);
+
+ // Base random walk
+ const drift = (Math.random() - 0.5) * 1.5;
+ const volatility = (Math.random() - 0.5) * 2.0;
+ price += drift + volatility;
+
+ // Clamp the price within 100 – 130 range
+ price = Math.min(130, Math.max(100, price));
+
+ // Realistic OHLC pattern
+ const open = price + (Math.random() - 0.5) * 1.2;
+ const close = price + (Math.random() - 0.5) * 1.2;
+
+ const high = Math.max(open, close) + Math.random() * 1.5;
+ const low = Math.min(open, close) - Math.random() * 1.5;
+
+ // Volume (random but smooth)
+ const volume = Math.floor(120 + Math.random() * 300);
+
+ data.push({
+ time: ts,
+ open: parseFloat(open.toFixed(2)),
+ high: parseFloat(high.toFixed(2)),
+ low: parseFloat(low.toFixed(2)),
+ close: parseFloat(close.toFixed(2)),
+ volume,
+ });
+ }
+
+ return data;
+}
+
+const candles = generateOhlcv(500);
+fs.writeFileSync(
+ "./mockOhlcv.ts",
+ "export const mockOhlcv = " + JSON.stringify(candles, null, 2) + ";\n"
+);
+
+console.log("Generated mockOhlcv.ts with 500 candles!");
diff --git a/frontend/app/home/page.tsx b/frontend/app/home/page.tsx
index 01ae195..9920d99 100644
--- a/frontend/app/home/page.tsx
+++ b/frontend/app/home/page.tsx
@@ -1,98 +1,67 @@
'use client';
-import { useState, useEffect } from 'react';
-import UserProfileCard from './components/UserInfo';
+import { useEffect } from 'react';
import TournamentContainer from './components/TournamentStatusBar';
-import CandleChart from './components/CandleChart';
import RecentTrades from './components/RecentTrades';
import AgentPerformanceChart from './components/AgentPerformanceChart';
import { useTournaments } from '@/src/hooks/useTournaments';
+import { useTournamentStore } from '@/src/store/useTournamentStore';
+import SectionBackground from '../components/background/SectionBackground';
+import BetModal from '@/src/betting/BetModal';
export default function HomePage() {
- // Fetch tournaments from backend
- const { data: tournaments, isLoading, error } = useTournaments();
+ // Fetch tournaments from backend (optional - for syncing with backend data)
+ const { data: tournaments } = useTournaments();
- // Add tournament state management - will be set to first tournament ID
- const [selectedTournamentId, setSelectedTournamentId] = useState(null);
+ // Use Zustand store for tournament selection (same as TournamentStatusBar)
+ const selectedTournamentId = useTournamentStore((s) => s.selectedTournamentId);
+ const setTournamentId = useTournamentStore((s) => s.setTournamentId);
- // Set initial tournament ID when tournaments load
+ // Sync with backend tournaments when they load (optional - updates default if needed)
useEffect(() => {
- console.log('HomePage useEffect:', { tournaments, isLoading, selectedTournamentId });
- if (tournaments && tournaments.length > 0 && !selectedTournamentId) {
- console.log('Setting tournament ID to:', tournaments[0].id);
- setSelectedTournamentId(tournaments[0].id);
+ if (tournaments && tournaments.length > 0) {
+ // Only update if the current ID doesn't exist in backend tournaments
+ const currentExists = tournaments.some(t => String(t.id) === selectedTournamentId);
+ if (!currentExists) {
+ setTournamentId(String(tournaments[0].id), tournaments[0].name);
+ }
}
- }, [tournaments, isLoading]);
-
- // Debug logging
- useEffect(() => {
- if (error) {
- console.error('Error loading tournaments:', error);
- }
- }, [error]);
+ }, [tournaments, selectedTournamentId, setTournamentId]);
return (
-
- {/* Animated gradient background */}
-
- {/* Animated orbs */}
-
-
-
-
-
- {/* Grid pattern overlay */}
-
-
- {error ? (
-
-
Error loading tournaments
-
{error.message}
-
- ) : isLoading ? (
-
-
Loading tournaments...
-
-
- ) : !selectedTournamentId ? (
-
-
No tournaments available
-
Tournaments loaded: {tournaments?.length || 0}
-
- ) : (
-
- {/* Section 1: User Profile & Tournament - Blue tint */}
-
-
-
- {/* User Profile Card */}
-
-
-
-
- {/* Tournament Container */}
-
-
-
-
+
+
+
+
- {/* Section 2: Agent Performance Chart - Purple tint */}
-
-
+
+
+
- {/* Section 3: Recent Trades - Cyan tint */}
-
);
}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 3b864ee..e22f69e 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
-import Navbar from "@/components/Navbar";
-import Footer from "@/components/Footer";
import { Providers } from "./providers";
+import { Toaster } from "react-hot-toast";
+import { ConditionalLayout } from "./components/ConditionalLayout";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -32,9 +32,21 @@ export default function RootLayout({
suppressHydrationWarning
>
-
- {children}
-
+ {/* Global Toast Renderer */}
+
+
+ {/* Conditionally render Navbar/Footer based on route */}
+ {children}