From ca74c69a999f9aa406efc2484542eefe0e2eae49 Mon Sep 17 00:00:00 2001 From: Joseph Nguyen Date: Thu, 27 Nov 2025 23:58:07 -0800 Subject: [PATCH 01/28] Foundation for new AgentList section --- frontend/app/home/dashboard/AgentCard.tsx | 115 ++++++++++++ .../app/home/dashboard/AgentListSection.tsx | 177 ++++++++++++++++++ frontend/app/home/data/mockAgents.ts | 82 ++++++++ frontend/app/home/page.tsx | 8 + frontend/lib/types/AgentBet.ts | 19 ++ frontend/src/store/useTournamentStore.ts | 31 +++ 6 files changed, 432 insertions(+) create mode 100644 frontend/app/home/dashboard/AgentCard.tsx create mode 100644 frontend/app/home/dashboard/AgentListSection.tsx create mode 100644 frontend/app/home/data/mockAgents.ts create mode 100644 frontend/lib/types/AgentBet.ts create mode 100644 frontend/src/store/useTournamentStore.ts diff --git a/frontend/app/home/dashboard/AgentCard.tsx b/frontend/app/home/dashboard/AgentCard.tsx new file mode 100644 index 0000000..7a37af5 --- /dev/null +++ b/frontend/app/home/dashboard/AgentCard.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React, { useState } from 'react'; +import { ChevronDown, ChevronUp, Info } from 'lucide-react'; +import { AgentBet } from '@/lib/types/AgentBet'; + +interface AgentCardProps { + agent: AgentBet; + totalAgents: number; + forceExpand?: boolean; // ⭐ ADDED +} + +export default function AgentCard({ agent, totalAgents, forceExpand }: AgentCardProps) { + const [expanded, setExpanded] = useState(false); + + // ⭐ Sync global expand/collapse + React.useEffect(() => { + if (forceExpand !== undefined) { + setExpanded(forceExpand); + } + }, [forceExpand]); + + return ( +
+ {/* MAIN ROW */} +
+
+ + {agent.name} + + + Win rate: {agent.winRate}% + + + Portfolio: ${agent.portfolioValue.toLocaleString()} + +
+ +
+ + {agent.odds}x + + + + + +
+
+ + {expanded && ( +
+ {agent.personality && ( +
+ Personality:{' '} + {agent.personality} +
+ )} + +
+ "{agent.quote || 'This agent has not posted recently.'}" +
+ +
+ {agent.rank !== undefined && ( +
+ Rank:{' '} + {agent.rank} of {totalAgents} + +
+ Rank shows this agent’s current position in the tournament leaderboard out of all agents. +
+
+ )} + +
+ P&L:{' '} + {agent.pnl !== undefined + ? `$${agent.pnl.toLocaleString()}` + : '—'} +
+ + {agent.volatility !== undefined && ( +
+ Volatility:{' '} + {agent.volatility} + +
+ Volatility measures how much this agent’s portfolio value swings over time. +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/app/home/dashboard/AgentListSection.tsx b/frontend/app/home/dashboard/AgentListSection.tsx new file mode 100644 index 0000000..d7d9230 --- /dev/null +++ b/frontend/app/home/dashboard/AgentListSection.tsx @@ -0,0 +1,177 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import AgentCard from './AgentCard'; +import { agents as mockAgents } from '../data/mockAgents'; + +export default function AgentListSection() { + const [forceExpandAll, setForceExpandAll] = useState(undefined); + + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + + // Accordion: store ID of open card OR null + const [openId, setOpenId] = useState(null); + + // Sorting + const [sortBy, setSortBy] = useState<'rank' | 'winRate' | 'odds'>('rank'); + + // Search bar + const [search, setSearch] = useState(''); + + // ------------------------------- + // PROCESSING (search + sorting) + // ------------------------------- + const processedAgents = useMemo(() => { + let list = [...mockAgents]; + + // ⭐ SEARCH FILTER + if (search.trim() !== '') { + list = list.filter((a) => + a.name.toLowerCase().includes(search.toLowerCase()) + ); + } + + 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; + }, [sortBy, search]); + + // Total AFTER filtering + const totalAgentsShown = processedAgents.length; + + // ------------------------------- + // HANDLE ACCORDION TOGGLE + // ------------------------------- + const handleAccordionToggle = (id: string) => { + setForceExpandAll(undefined); // turn off global expand mode + + // If clicking the same open card → close it + setOpenId((prev) => (prev === id ? null : id)); + }; + + return ( +
+ + {/* ======================================================= */} + {/* HEADER (Sticky) */} + {/* ======================================================= */} +
+ + + {/* Title + Count */} +
+

+ Agents in This Tournament +

+ + + Total Agents: {mockAgents.length} + {totalAgentsShown !== mockAgents.length && ( + <> • Showing: {totalAgentsShown} + )} + +
+ + {/* ======================================================= */} + {/* CONTROLS ROW */} + {/* ======================================================= */} +
+ + + {/* Expand All */} + + + {/* Collapse All */} + + + {/* Sorting Dropdown */} + + + + + + {/* Search Bar */} + 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) => { + let forceExpandProp: boolean | undefined = undefined; + + // ⭐ Global expand/collapse has priority + if (forceExpandAll !== undefined) { + forceExpandProp = forceExpandAll; + } else { + // ⭐ Accordion mode: only open the selected card + forceExpandProp = openId === agent.id; + } + + return ( +
handleAccordionToggle(agent.id)} + className="cursor-pointer" + > + +
+ ); + })} +
+
+ ); +} diff --git a/frontend/app/home/data/mockAgents.ts b/frontend/app/home/data/mockAgents.ts new file mode 100644 index 0000000..ea3360a --- /dev/null +++ b/frontend/app/home/data/mockAgents.ts @@ -0,0 +1,82 @@ +import { AgentBet } from '@/lib/types/AgentBet'; + +export const agents: AgentBet[] = [ + { + id: "3", + tournamentId: "5", + name: "Safe Mode Sarah", + personality: "risk-averse", + quote: "Protect capital first. Grow second.", + winRate: 71, + portfolioValue: 1322.10, + odds: 2.4, + rank: 4, + pnl: 322.10, + volatility: 9, + }, + { + id: "1", + tournamentId: "5", + name: "Diamond Hands Dan", + personality: "maximalist", + quote: "Volatility is temporary. Conviction is forever.", + winRate: 62, + portfolioValue: 1245.32, + odds: 1.8, + rank: 2, + pnl: 245.32, + volatility: 18, + }, + { + id: "5", + tournamentId: "5", + name: "Sniper Bot", + personality: "precision trader", + quote: "Entry: perfect. Exit: perfect. Emotions: none.", + winRate: 54, + portfolioValue: 1210.89, + odds: 3.1, + rank: 6, + pnl: 210.89, + volatility: 28, + }, + { + id: "2", + tournamentId: "5", + name: "YOLO Trader", + personality: "degen", + quote: "I went all-in at 3am. No regrets.", + winRate: 48, + portfolioValue: 1181.55, + odds: 1.6, + rank: 1, + pnl: 181.55, + volatility: 72, + }, + { + id: "6", + tournamentId: "5", + name: "Quantum Gambler", + personality: "chaotic neutral", + quote: "The quantum fractals told me this was the move.", + winRate: 36, + portfolioValue: 1034.52, + odds: 5.1, + rank: 3, + pnl: 34.52, + volatility: 81, + }, + { + id: "4", + tournamentId: "5", + name: "Paper Hands Pete", + personality: "anxious seller", + quote: "I'm diamond hands... until the chart dips 1%. Then I'm out.", + winRate: 29, + portfolioValue: 912.44, + odds: 2.9, + rank: 5, + pnl: -87.56, + volatility: 55, + } +]; diff --git a/frontend/app/home/page.tsx b/frontend/app/home/page.tsx index abb8bd1..f372e34 100644 --- a/frontend/app/home/page.tsx +++ b/frontend/app/home/page.tsx @@ -5,6 +5,9 @@ import UserProfileCard from './components/UserInfo'; import TournamentContainer from './components/TournamentStatusBar'; import CandleChart from './components/CandleChart'; import RecentTrades from './components/RecentTrades'; +import AgentListSection from './dashboard/AgentListSection'; +import { agents } from './data/mockAgents'; + export default function HomePage() { // Add tournament state management @@ -43,6 +46,11 @@ export default function HomePage() { +
+
+ +
+ {/* Section 2: Chart - Purple tint */}
diff --git a/frontend/lib/types/AgentBet.ts b/frontend/lib/types/AgentBet.ts new file mode 100644 index 0000000..d4ca80b --- /dev/null +++ b/frontend/lib/types/AgentBet.ts @@ -0,0 +1,19 @@ +import { Agent } from '@/src/types'; + +export interface AgentBet extends Agent { + // tournament linkage for Zustand filtering later + tournamentId: string; + + // Betting UI fields + winRate: number; + portfolioValue: number; + odds: number; + + // Optional extended stats + rank?: number; + pnl?: number; + volatility?: number; + + // UI-only content + quote?: string; +} diff --git a/frontend/src/store/useTournamentStore.ts b/frontend/src/store/useTournamentStore.ts new file mode 100644 index 0000000..d41e219 --- /dev/null +++ b/frontend/src/store/useTournamentStore.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; +import type { AgentBet } from '@/lib/types/AgentBet'; + +interface TournamentStore { + // Tournament selection (GLOBAL) + selectedTournamentId: number; + setTournamentId: (id: number) => void; + + // Bet modal (GLOBAL UI state) + isBetModalOpen: boolean; + selectedAgent: AgentBet | null; + openBetModal: (agent: AgentBet) => void; + closeBetModal: () => void; +} + +export const useTournamentStore = create((set) => ({ + // Default tournament + selectedTournamentId: 5, + + setTournamentId: (id) => set({ selectedTournamentId: id }), + + // --- Bet modal state --- + isBetModalOpen: false, + selectedAgent: null, + + openBetModal: (agent) => + set({ selectedAgent: agent, isBetModalOpen: true }), + + closeBetModal: () => + set({ selectedAgent: null, isBetModalOpen: false }), +})); From f00a44ee77fdb92da73c9f0e24882904123c3bbd Mon Sep 17 00:00:00 2001 From: Joseph Nguyen Date: Fri, 28 Nov 2025 00:30:13 -0800 Subject: [PATCH 02/28] Zustand attempt for new agent list integration and bet modal --- frontend/app/home/components/BetModal.tsx | 92 +++++++++++++++++++ frontend/app/home/components/CandleChart.tsx | 53 ++++++----- frontend/app/home/components/RecentTrades.tsx | 48 +++++----- .../home/components/TournamentStatusBar.tsx | 69 +++++++------- frontend/app/home/dashboard/AgentCard.tsx | 33 +++++-- .../app/home/dashboard/AgentListSection.tsx | 84 +++++++---------- frontend/app/home/page.tsx | 34 ++++--- frontend/src/store/useTournamentStore.ts | 6 +- 8 files changed, 255 insertions(+), 164 deletions(-) create mode 100644 frontend/app/home/components/BetModal.tsx diff --git a/frontend/app/home/components/BetModal.tsx b/frontend/app/home/components/BetModal.tsx new file mode 100644 index 0000000..3ca752f --- /dev/null +++ b/frontend/app/home/components/BetModal.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useTournamentStore } from '@/src/store/useTournamentStore'; +import { X } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { useState } from 'react'; + +export default function BetModal() { + const { isBetModalOpen, selectedAgent, closeBetModal } = useTournamentStore(); + const [amount, setAmount] = useState(''); + + // If modal closed → render nothing + if (!isBetModalOpen || !selectedAgent) return null; + + const { name, odds, winRate, rank, portfolioValue } = selectedAgent; + + return ( +
+ + {/* Close Button */} + + +

+ Bet on {name} +

+

+ Odds: {odds}x +

+ + {/* Agent Info */} +
+

+ Win Rate: {winRate}% +

+ + {rank !== undefined && ( +

+ Rank: #{rank} +

+ )} + +

+ Portfolio: ${portfolioValue.toLocaleString()} +

+
+ + {/* Amount Input */} +
+ + setAmount(e.target.value)} + className="px-3 py-2 rounded-lg bg-black/30 border border-white/10 text-white" + /> +
+ + {/* Buttons */} +
+ + +
+
+
+ ); +} diff --git a/frontend/app/home/components/CandleChart.tsx b/frontend/app/home/components/CandleChart.tsx index 23d5434..a338622 100644 --- a/frontend/app/home/components/CandleChart.tsx +++ b/frontend/app/home/components/CandleChart.tsx @@ -12,21 +12,22 @@ import { getOhlcvByTournament } from '../data/mockOhlcv'; import type { UTCTimestamp, ISeriesApi, IChartApi } from 'lightweight-charts'; import { CandlestickSeries } from 'lightweight-charts'; +// Zustand store +import { useTournamentStore } from '@/src/store/useTournamentStore'; + /* ----------------------------- Types ----------------------------- */ -// A single OHLC candle in Lightweight Charts format export interface OhlcCandle { - time: UTCTimestamp; // seconds since epoch + time: UTCTimestamp; open: number; high: number; low: number; close: number; - // volume is optional in case you add it later volume?: number; } interface CandleChartProps { - tournamentId: number; + tournamentId?: number; // now optional } /* --------------------- Local aggregation utils ------------------ */ @@ -51,7 +52,6 @@ function aggregateOhlc(data: OhlcCandle[], groupSize: number): OhlcCandle[] { high, low, close, - // If volume exists, sum it: volume: group.reduce((acc, c) => acc + (c.volume ?? 0), 0) || undefined, }); } @@ -60,7 +60,7 @@ function aggregateOhlc(data: OhlcCandle[], groupSize: number): OhlcCandle[] { } const timeframeMap: Record = { - '1m': 1, // mock fallback (still 5m resolution) + '1m': 1, '5m': 1, '15m': 3, '1h': 12, @@ -71,16 +71,26 @@ const timeframeMap: Record = { /* --------------------------- Component --------------------------- */ export default function CandleChart({ tournamentId }: CandleChartProps) { + // Zustand fallback + const storeTournamentId = Number( + useTournamentStore((s) => s.selectedTournamentId) + ); + + // Final tournament ID that chart actually uses + const finalTournamentId = tournamentId ?? storeTournamentId; + const chartContainerRef = useRef(null); - const [timeframe, setTimeframe] = useState<'1m' | '5m' | '15m' | '1h' | '4h' | '1d'>('1h'); + const [timeframe, setTimeframe] = useState< + '1m' | '5m' | '15m' | '1h' | '4h' | '1d' + >('1h'); const [isFullscreen, setIsFullscreen] = useState(false); - // raw mock data typed - const rawData = getOhlcvByTournament(tournamentId) as OhlcCandle[]; + // Load data using the FINAL tournament ID + const rawData = getOhlcvByTournament(finalTournamentId) as OhlcCandle[]; const groupSize = timeframeMap[timeframe] ?? 1; const ohlcv = aggregateOhlc(rawData, groupSize); - // ESC exits fullscreen + /* ---------------- ESC exits fullscreen ---------------- */ useEffect(() => { const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsFullscreen(false); @@ -89,6 +99,7 @@ export default function CandleChart({ tournamentId }: CandleChartProps) { return () => window.removeEventListener('keydown', handleEsc); }, []); + /* ---------------- Chart Init + Resize ---------------- */ useEffect(() => { const container = chartContainerRef.current; if (!container || ohlcv.length === 0) return; @@ -96,14 +107,11 @@ export default function CandleChart({ tournamentId }: CandleChartProps) { 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 ctn = chartContainerRef.current; - - // clear old canvases (prevents double charts) - ctn.innerHTML = ''; + ctn.innerHTML = ''; // reset old chart chart = LightweightCharts.createChart(ctn, { width: ctn.clientWidth, @@ -146,7 +154,6 @@ export default function CandleChart({ tournamentId }: CandleChartProps) { window.addEventListener('resize', handleResize); - // cleanup for this import callback return () => { window.removeEventListener('resize', handleResize); chart?.remove(); @@ -155,18 +162,15 @@ export default function CandleChart({ tournamentId }: CandleChartProps) { }; }); - // 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]); + }, [isFullscreen, timeframe, finalTournamentId, ohlcv]); - const timeframeButtons: Array<'1m' | '5m' | '15m' | '1h' | '4h' | '1d'> = [ - '1m', '5m', '15m', '1h', '4h', '1d', - ]; + /* ---------------- Calculations ---------------- */ const currentPrice = ohlcv[ohlcv.length - 1]?.close ?? 0; const firstPrice = ohlcv[0]?.close ?? currentPrice; @@ -175,6 +179,8 @@ export default function CandleChart({ tournamentId }: CandleChartProps) { const volume24h = 1_200_000; + /* ---------------- UI (UNCHANGED) ---------------- */ + return (

Portfolio Value

-

Tournament #{tournamentId}

+

Tournament #{finalTournamentId}

+

${currentPrice.toFixed(2)} @@ -219,10 +226,10 @@ export default function CandleChart({ tournamentId }: CandleChartProps) { {/* Timeframe + Fullscreen */}

- {timeframeButtons.map((tf) => ( + {['1m', '5m', '15m', '1h', '4h', '1d'].map((tf) => (
@@ -97,7 +104,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) { {/* trades list */}
- + {filteredTrades.length === 0 ? (
@@ -142,7 +150,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) { animate={{ opacity: 1, x: 0 }} transition={{ delay: index * 0.05 }} > - {/* glow effect on hover */} + {/* glow */}
- {/* left side: icon + info */} + {/* left */}
- {/* action icon */}
- {/* trade action & token */}
- {/* amount */}

Amount: {formatCurrency(trade.amount_usd)}

- {/* right side: price + time */} + {/* right */}

${trade.price_usd.toLocaleString()}

-
{formatTime(trade.timestamp)}
- {/* pulse indicator for recent trades */} - {index === 0 && isLiveTournament &&( + {/* live pulse indicator */} + {index === 0 && isLiveTournament && ( 0 && (
- {/* total buy volume */} + {/* total buy */}
@@ -244,7 +248,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {

- {/* total sell volume */} + {/* total sell */}
@@ -264,7 +268,7 @@ export default function RecentTrades({ tournamentId }: RecentTradesProps) {
)} - {/* bottom accent line */} + {/* bottom accent */}
); diff --git a/frontend/app/home/components/TournamentStatusBar.tsx b/frontend/app/home/components/TournamentStatusBar.tsx index 6707b35..b3a27be 100644 --- a/frontend/app/home/components/TournamentStatusBar.tsx +++ b/frontend/app/home/components/TournamentStatusBar.tsx @@ -4,22 +4,22 @@ import React, { useState } from 'react'; import { mockTournaments } from '../data/mockTournament'; import AgentPositions from './AgentPositions'; import ActiveBets from './ActiveBets'; +import { useTournamentStore } from '@/src/store/useTournamentStore'; -interface TournamentContainerProps { - selectedTournamentId: number; - onTournamentChange: (id: number) => void; -} - -function TournamentStatusBar({ - tournament, - onTournamentChange, -}: { - tournament: (typeof mockTournaments)[0]; - onTournamentChange: (tournamentId: number) => void; -}) { +/* --------------------------------------------------------- + TOURNAMENT STATUS BAR (now a NAMED export, NOT default) +--------------------------------------------------------- */ +export function TournamentStatusBar() { const [isDropdownOpen, setIsDropdownOpen] = useState(false); - // Format the end time nicely + // Zustand store + const selectedTournamentId = useTournamentStore((s) => s.selectedTournamentId); + const setTournamentId = useTournamentStore((s) => s.setTournamentId); + + const tournament = + mockTournaments.find((t) => t.id === selectedTournamentId) || + mockTournaments[0]; + const formattedEndTime = new Date(tournament.end_time).toLocaleDateString( 'en-US', { @@ -29,7 +29,6 @@ function TournamentStatusBar({ } ); - // Get status badge color const getStatusColor = (status: string) => { switch (status) { case 'LIVE': @@ -48,7 +47,6 @@ function TournamentStatusBar({
TOURNAMENT
- {/* Dropdown Button with Triangle */}
- {/* Dropdown Menu */} {isDropdownOpen && (
@@ -71,7 +68,7 @@ function TournamentStatusBar({
); } -export default function TournamentContainer({ - selectedTournamentId, - onTournamentChange, -}: TournamentContainerProps) { - // Find the selected tournament - const selectedTournament = - mockTournaments.find((t) => t.id === selectedTournamentId) || - mockTournaments[0]; +/* --------------------------------------------------------- + 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 */}
diff --git a/frontend/app/home/dashboard/AgentCard.tsx b/frontend/app/home/dashboard/AgentCard.tsx index 7a37af5..cef0834 100644 --- a/frontend/app/home/dashboard/AgentCard.tsx +++ b/frontend/app/home/dashboard/AgentCard.tsx @@ -3,17 +3,25 @@ import React, { useState } from 'react'; import { ChevronDown, ChevronUp, Info } from 'lucide-react'; import { AgentBet } from '@/lib/types/AgentBet'; +import { useTournamentStore } from '@/src/store/useTournamentStore'; // ⭐ ADDED interface AgentCardProps { agent: AgentBet; totalAgents: number; - forceExpand?: boolean; // ⭐ ADDED + forceExpand?: boolean; } -export default function AgentCard({ agent, totalAgents, forceExpand }: AgentCardProps) { +export default function AgentCard({ + agent, + totalAgents, + forceExpand, +}: AgentCardProps) { const [expanded, setExpanded] = useState(false); - // ⭐ Sync global expand/collapse + // ⭐ Zustand bet modal + const openBetModal = useTournamentStore((s) => s.openBetModal); + + // Sync global expand/collapse React.useEffect(() => { if (forceExpand !== undefined) { setExpanded(forceExpand); @@ -44,13 +52,23 @@ export default function AgentCard({ agent, totalAgents, forceExpand }: AgentCard {agent.odds}x -
+ {/* EXPANDED CONTENT */} {expanded && (
{agent.personality && ( @@ -92,9 +111,7 @@ export default function AgentCard({ agent, totalAgents, forceExpand }: AgentCard
P&L:{' '} - {agent.pnl !== undefined - ? `$${agent.pnl.toLocaleString()}` - : '—'} + {agent.pnl !== undefined ? `$${agent.pnl.toLocaleString()}` : '—'}
{agent.volatility !== undefined && ( diff --git a/frontend/app/home/dashboard/AgentListSection.tsx b/frontend/app/home/dashboard/AgentListSection.tsx index d7d9230..504d114 100644 --- a/frontend/app/home/dashboard/AgentListSection.tsx +++ b/frontend/app/home/dashboard/AgentListSection.tsx @@ -4,91 +4,81 @@ import React, { useState, useMemo } from 'react'; import AgentCard from './AgentCard'; import { agents as mockAgents } from '../data/mockAgents'; +// Zustand +import { useTournamentStore } from '@/src/store/useTournamentStore'; + export default function AgentListSection() { const [forceExpandAll, setForceExpandAll] = useState(undefined); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - - - // Accordion: store ID of open card OR null const [openId, setOpenId] = useState(null); - - // Sorting const [sortBy, setSortBy] = useState<'rank' | 'winRate' | 'odds'>('rank'); - - // Search bar const [search, setSearch] = useState(''); + // 🔥 Read tournament ID from Zustand + const selectedTournamentId = Number( + useTournamentStore((s) => s.selectedTournamentId) + ); + // ------------------------------- - // PROCESSING (search + sorting) + // PROCESSING (filter + search + sort) // ------------------------------- const processedAgents = useMemo(() => { - let list = [...mockAgents]; + // 🔥 1) FILTER agents by tournament + let list = mockAgents.filter( + (a) => Number(a.tournamentId) === selectedTournamentId + ); - // ⭐ SEARCH FILTER + // 2) Search if (search.trim() !== '') { list = list.filter((a) => a.name.toLowerCase().includes(search.toLowerCase()) ); } + // 3) Sorting list.sort((a, b) => { - let result = 0; + 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; + 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 sortDirection === 'asc' ? result : -result; }); - return list; - }, [sortBy, search]); + }, [sortBy, sortDirection, search, selectedTournamentId]); - // Total AFTER filtering const totalAgentsShown = processedAgents.length; - // ------------------------------- - // HANDLE ACCORDION TOGGLE - // ------------------------------- + // Accordion toggle const handleAccordionToggle = (id: string) => { - setForceExpandAll(undefined); // turn off global expand mode - - // If clicking the same open card → close it + setForceExpandAll(undefined); setOpenId((prev) => (prev === id ? null : id)); }; return (
- - {/* ======================================================= */} - {/* HEADER (Sticky) */} - {/* ======================================================= */} + {/* Header */}
- - {/* Title + Count */}

Agents in This Tournament

- Total Agents: {mockAgents.length} + Total Agents: {mockAgents.filter( + (a) => Number(a.tournamentId) === selectedTournamentId + ).length} {totalAgentsShown !== mockAgents.length && ( <> • Showing: {totalAgentsShown} )}
- {/* ======================================================= */} - {/* CONTROLS ROW */} - {/* ======================================================= */} + {/* Controls */}
- - - {/* Expand All */} - {/* Collapse All */} - {/* Sorting Dropdown */} setSortDirection(e.target.value as any)} - className="px-3 py-2 text-sm bg-slate-800 text-white rounded-lg"> - + className="px-3 py-2 text-sm bg-slate-800 text-white rounded-lg" + > + - - + - {/* Search Bar */}
- {/* ======================================================= */} - {/* AGENT LIST */} - {/* ======================================================= */} + {/* Agent List */}
- {processedAgents.map((agent) => { let forceExpandProp: boolean | undefined = undefined; - // ⭐ Global expand/collapse has priority if (forceExpandAll !== undefined) { forceExpandProp = forceExpandAll; } else { - // ⭐ Accordion mode: only open the selected card forceExpandProp = openId === agent.id; } diff --git a/frontend/app/home/page.tsx b/frontend/app/home/page.tsx index f372e34..5d64270 100644 --- a/frontend/app/home/page.tsx +++ b/frontend/app/home/page.tsx @@ -1,68 +1,66 @@ 'use client'; -import { useState } from 'react'; import UserProfileCard from './components/UserInfo'; import TournamentContainer from './components/TournamentStatusBar'; import CandleChart from './components/CandleChart'; import RecentTrades from './components/RecentTrades'; import AgentListSection from './dashboard/AgentListSection'; -import { agents } from './data/mockAgents'; - +import BetModal from './components/BetModal'; // ⭐ ADD THIS +import { useTournamentStore } from '@/src/store/useTournamentStore'; export default function HomePage() { - // Add tournament state management - const [selectedTournamentId, setSelectedTournamentId] = useState(5); + const selectedTournamentId = useTournamentStore((s) => s.selectedTournamentId); return (
{/* Animated gradient background */}
- {/* Animated orbs */}
- {/* Grid pattern overlay */} + {/* Grid pattern */}
- {/* Section 1: User Profile & Tournament - Blue tint */} + + {/* Section 1: User Profile + Tournament */}
- {/* User Profile Card */}
- {/* Tournament Container */}
- +
+ {/* Agent List */}
- {/* Section 2: Chart - Purple tint */} + {/* Chart */}
- +
- {/* Section 3: Recent Trades - Cyan tint */} + {/* Recent Trades */}
- +
+
+ + {/* ⭐ ADD BET MODAL HERE — always mounted, Zustand controls visibility */} +
); } diff --git a/frontend/src/store/useTournamentStore.ts b/frontend/src/store/useTournamentStore.ts index d41e219..d2862cc 100644 --- a/frontend/src/store/useTournamentStore.ts +++ b/frontend/src/store/useTournamentStore.ts @@ -2,11 +2,9 @@ import { create } from 'zustand'; import type { AgentBet } from '@/lib/types/AgentBet'; interface TournamentStore { - // Tournament selection (GLOBAL) selectedTournamentId: number; setTournamentId: (id: number) => void; - // Bet modal (GLOBAL UI state) isBetModalOpen: boolean; selectedAgent: AgentBet | null; openBetModal: (agent: AgentBet) => void; @@ -14,12 +12,10 @@ interface TournamentStore { } export const useTournamentStore = create((set) => ({ - // Default tournament selectedTournamentId: 5, - setTournamentId: (id) => set({ selectedTournamentId: id }), + setTournamentId: (id) => set({ selectedTournamentId: Number(id) }), - // --- Bet modal state --- isBetModalOpen: false, selectedAgent: null, From 08cc05e6bbc514bfaa3b50772bfe26a6d59a85e4 Mon Sep 17 00:00:00 2001 From: Joseph Nguyen Date: Wed, 3 Dec 2025 20:21:27 -0800 Subject: [PATCH 03/28] Full Dashboard v2 + Backend API updates: - New home dashboard UI (agents, trades, bets, chart) - Updated mock data - Added agent API router - Updated market data and trading endpoints - Added pyproject + tools for backend --- backend/app/api/main.py | 2 +- backend/app/api/routers/agent.py | 29 +- backend/app/api/routers/agents.py | 170 + backend/app/api/routers/auth.py | 49 +- backend/app/api/routers/bet.py | 36 +- backend/app/api/routers/market_data.py | 7 +- backend/app/api/routers/tournament.py | 39 +- backend/app/api/routers/trade.py | 9 +- backend/app/db/database.py | 4 +- backend/pyproject.toml | 2 + backend/tools/market_data.py | 55 + frontend/app/components/FeaturedAgents.tsx | 34 +- frontend/app/components/ui/agents.tsx | 228 +- frontend/app/home/components/ActiveBets.tsx | 334 +- .../app/home/components/AgentPositions.tsx | 212 +- .../app/home/components/BettingOverview.tsx | 75 +- frontend/app/home/components/CandleChart.tsx | 480 +- .../app/home/components/DashboardHeader.tsx | 55 +- frontend/app/home/components/PastBets.tsx | 45 +- frontend/app/home/components/RecentTrades.tsx | 309 +- frontend/app/home/data/generateOhlcv.js | 53 + frontend/app/home/data/mockAgents.ts | 69 + frontend/app/home/data/mockBets.ts | 59 + frontend/app/home/data/mockOhlcv.ts | 191 +- frontend/app/home/data/mockPositions.ts | 52 +- frontend/app/home/data/mockTradeCandle.ts | 53 + frontend/app/home/data/mockTrades.ts | 152 +- frontend/package-lock.json | 11827 ++++++++++++++++ frontend/package.json | 2 +- frontend/src/util/aggregateOhlcv.ts | 54 + 30 files changed, 13511 insertions(+), 1175 deletions(-) create mode 100644 backend/app/api/routers/agents.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tools/market_data.py create mode 100644 frontend/app/home/data/generateOhlcv.js create mode 100644 frontend/app/home/data/mockAgents.ts create mode 100644 frontend/app/home/data/mockBets.ts create mode 100644 frontend/app/home/data/mockTradeCandle.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/src/util/aggregateOhlcv.ts diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 1b5ec07..06c3857 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI -from backend.app.api.routers import tournament, agent, trade, bet, auth, market_data +from app.api.routers import tournament, agent, trade, bet, auth, market_data app = FastAPI(title="Agonus API") diff --git a/backend/app/api/routers/agent.py b/backend/app/api/routers/agent.py index 961720d..09f87d6 100644 --- a/backend/app/api/routers/agent.py +++ b/backend/app/api/routers/agent.py @@ -3,10 +3,12 @@ from sqlalchemy import select from uuid import UUID -from backend.app.db.database import get_db -from backend.app.db.models import Agent -from backend.app.schemas.agent import AgentCreate, AgentUpdate, AgentResponse -from backend.app.api.deps import require_admin +# FIXED IMPORT PATHS ↓↓↓↓↓↓↓↓ +from app.db.database import get_db +from app.db.models import Agent +from app.schemas.agent import AgentCreate, AgentUpdate, AgentResponse +from app.api.deps import require_admin +# ↑↑↑↑↑↑ FIXED IMPORT PATHS router = APIRouter() @@ -36,7 +38,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 +48,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 +75,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 +99,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 +120,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 +135,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/agents.py b/backend/app/api/routers/agents.py new file mode 100644 index 0000000..4fd7d53 --- /dev/null +++ b/backend/app/api/routers/agents.py @@ -0,0 +1,170 @@ +from fastapi import APIRouter, HTTPException +from typing import Dict, Any +from datetime import datetime + +# ❗ FIXED IMPORT PATHS — REMOVE "backend." +from app.tools.market_data import MarketDataTool +from app.agents.executor import ACTIVE_AGENTS + +router = APIRouter(prefix="/api/agents", tags=["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/auth.py b/backend/app/api/routers/auth.py index f7499e9..3cb9ce6 100644 --- a/backend/app/api/routers/auth.py +++ b/backend/app/api/routers/auth.py @@ -6,42 +6,61 @@ from web3 import Web3 import os -router = APIRouter() +# Add prefix + tags for consistency +router = APIRouter(prefix="/auth", tags=["Auth"]) 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" - + + # 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 115fe92..6b6980b 100644 --- a/backend/app/api/routers/bet.py +++ b/backend/app/api/routers/bet.py @@ -3,10 +3,11 @@ from sqlalchemy import select from uuid import UUID -from backend.app.db.database import get_db -from backend.app.db.models import Bet -from backend.app.schemas.bet import BetCreate, BetUpdate, BetResponse -from backend.app.api.deps import get_current_user +# ❗ FIXED: remove "backend." +from app.db.database import get_db +from app.db.models import Bet +from app.schemas.bet import BetCreate, BetUpdate, BetResponse +from app.api.deps import get_current_user router = APIRouter() @@ -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,8 @@ 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["user_address"] = user["address"] # Force bet ownership bet = Bet(**bet_dict) @@ -64,9 +63,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 +81,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 +103,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/tournament.py b/backend/app/api/routers/tournament.py index b13de58..a1739d1 100644 --- a/backend/app/api/routers/tournament.py +++ b/backend/app/api/routers/tournament.py @@ -3,16 +3,17 @@ from sqlalchemy import select from uuid import UUID -from backend.app.db.database import get_db -from backend.app.db.models import Tournament, AgentState, Agent -from backend.app.schemas.tournament import ( +# FIXED IMPORT PATHS +from app.db.database import get_db +from app.db.models import Tournament, AgentState, Agent +from app.schemas.tournament import ( TournamentCreate, TournamentUpdate, TournamentResponse, ) -from backend.app.schemas.agent_state import AgentStateResponse -from backend.app.api.deps import require_admin -from backend.app.agents.scheduler import ( +from app.schemas.agent_state import AgentStateResponse +from app.api.deps import require_admin +from app.agents.scheduler import ( run_agent_decision, initialize_tournament_agents, ) @@ -42,40 +43,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 + return result.scalars().all() @router.post("/", response_model=TournamentResponse, status_code=201) @@ -84,8 +77,7 @@ async def create_tournament( session: AsyncSession = Depends(get_db), admin: dict = Depends(require_admin), ): - """POST route to create a new tournament""" - # Create tournament from schema, excluding agent_ids (not a Tournament model field) + """POST create a new tournament""" tournament_dict = tournament_data.model_dump(exclude={"agent_ids"}) tournament = Tournament(**tournament_dict) @@ -106,11 +98,9 @@ async def start_tournament(tournament_id: str): @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"} @@ -121,12 +111,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(): @@ -145,7 +134,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 c2ec0f5..5de403d 100644 --- a/backend/app/api/routers/trade.py +++ b/backend/app/api/routers/trade.py @@ -3,9 +3,10 @@ from sqlalchemy import select from uuid import UUID -from backend.app.db.database import get_db -from backend.app.db.models import Trade -from backend.app.schemas.trade import TradeCreate, TradeResponse +# ❗ FIXED: remove "backend." +from app.db.database import get_db +from app.db.models import Trade +from app.schemas.trade import TradeCreate, TradeResponse router = APIRouter() @@ -68,11 +69,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 8d851d8..7459f52 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -5,9 +5,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/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/tools/market_data.py b/backend/tools/market_data.py new file mode 100644 index 0000000..4a4ddec --- /dev/null +++ b/backend/tools/market_data.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta +from typing import Dict, Optional +import logging + +logger = logging.getLogger(__name__) + +class MarketDataTool: + def __init__(self, api_base: str = None, api_key: str = None): + # ... existing code ... + self.api_base = api_base or "https://api.coingecko.com/api/v3" + self.api_key = api_key + + # Price caching to avoid hitting rate limits + self._price_cache: Dict[str, float] = {} + self._cache_timestamp: Optional[datetime] = None + self._cache_ttl_seconds: int = 5 # 5-second cache + + def _is_cache_valid(self) -> bool: + """Check if cached prices are still fresh.""" + if not self._cache_timestamp: + return False + + age = (datetime.utcnow() - self._cache_timestamp).total_seconds() + return age < self._cache_ttl_seconds + + def get_all_prices(self) -> Dict[str, float]: + """ + Get current USD prices for all supported tokens with caching. + + Returns: + Dict[str, float]: {"BTC": 42000.50, "ETH": 2300.25, ...} + """ + # Return cached prices if still valid + if self._is_cache_valid(): + return self._price_cache.copy() + + # Fetch fresh prices + prices = {} + for token in self.supported_tokens.keys(): + try: + price_data = self.get_price(token) + prices[token] = price_data[token.lower()]['usd'] + except Exception as e: + # Fallback to last known price if fetch fails + if token in self._price_cache: + prices[token] = self._price_cache[token] + logger.warning(f"Using cached price for {token} due to error: {e}") + else: + logger.error(f"Failed to fetch {token} price: {e}") + + # Update cache + self._price_cache = prices + self._cache_timestamp = datetime.utcnow() + + return prices.copy() \ No newline at end of file diff --git a/frontend/app/components/FeaturedAgents.tsx b/frontend/app/components/FeaturedAgents.tsx index 1f8f710..8d89395 100644 --- a/frontend/app/components/FeaturedAgents.tsx +++ b/frontend/app/components/FeaturedAgents.tsx @@ -1,22 +1,24 @@ 'use client'; -import { motion, useScroll, useTransform } from 'framer-motion'; -import { useRef } from 'react'; -import { agents } from './ui/agents'; -import { spacing, typography, layout, animations } from '../design-tokens'; +import { motion, useScroll, useTransform } from "framer-motion"; +import { useRef } from "react"; -// ⭐ Correct component import -import CenteredAgentCarousel from './CenteredAgentCarousel'; +import { spacing, typography, layout, animations } from "../design-tokens"; + +// Backend-format agents +import { mockAgents } from "@/app/home/data/mockAgents"; + +// Backend-correct AgentCard (used inside the carousel) +import CenteredAgentCarousel from "./CenteredAgentCarousel"; export default function FeaturedAgents() { const ref = useRef(null); const { scrollYProgress } = useScroll({ target: ref, - offset: ['start end', 'end start'], + 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]); @@ -26,7 +28,7 @@ export default function FeaturedAgents() { ref={ref} className={`${spacing.section.x} ${spacing.section.y} relative overflow-hidden`} > - {/* Background pattern parallax */} + {/* Background Pattern */} {/* Content */} - {/* Heading */} + {/* Title */}

- Meet the AI trading agents{' '} + Meet the AI trading agents{" "} competing in the tournament

- {/* ⭐ NEW Centered carousel */} - + {/* ⭐ The carousel already uses AgentCard internally */} +
); diff --git a/frontend/app/components/ui/agents.tsx b/frontend/app/components/ui/agents.tsx index d226b5e..41ee4a0 100644 --- a/frontend/app/components/ui/agents.tsx +++ b/frontend/app/components/ui/agents.tsx @@ -1,155 +1,163 @@ 'use client'; -import { motion } from 'framer-motion'; -import { TrendingUp, TrendingDown } from 'lucide-react'; -import {components, animations, typography, spacing, effects} from '../../design-tokens'; +import { motion } from "framer-motion"; +import { TrendingUp, TrendingDown } from "lucide-react"; +import { components, animations, typography } from "../../design-tokens"; import { Card, - CardContent, - CardDescription, - CardFooter, CardHeader, CardTitle, -} from './card'; - -export const agents = [ - { - id: 1, - name: 'Diamond Hands Dan', - emoji: '🤖', - strategy: 'Never sells, only buys dips', - winRate: 85, - badgeColor: 'blue', - badgeText: 'Passive Investor', - trend: 'up', - }, - { - id: 2, - name: 'Paper Hands Pete', - emoji: '😰', - strategy: 'Sells at first sign of trouble', - winRate: 62, - badgeColor: 'red', - badgeText: 'Quick Exit', - trend: 'down', - }, - { - id: 3, - name: 'YOLO Trader', - emoji: '🎲', - strategy: 'All-in on risky bets', - winRate: 78, - badgeColor: 'yellow', - badgeText: 'High Risk', - trend: 'up', - }, - { - id: 4, - name: 'Safe Mode Sarah', - emoji: '🛡️', - strategy: 'Conservative, plays it safe', - winRate: 71, - badgeColor: 'green', - badgeText: 'Low Risk', - trend: 'up', - }, - { - id: 5, - name: 'Sniper Bot', - emoji: '🎯', - strategy: 'Waits for perfect moment to strike', - winRate: 92, - badgeColor: 'purple', - badgeText: 'Precise', - trend: 'up', - }, -]; - -export type Agent = (typeof agents)[number]; - -const badgeColors = { - blue: 'bg-blue-400/10 text-blue-400 ring-1 ring-blue-400/30', - red: 'bg-red-400/10 text-red-400 ring-1 ring-red-400/30', - yellow: 'bg-yellow-400/10 text-yellow-500 ring-1 ring-yellow-400/30', - green: 'bg-green-400/10 text-green-400 ring-1 ring-green-400/30', - purple: 'bg-purple-400/10 text-purple-400 ring-1 ring-purple-400/30', -}; - -export function AgentCard({ - agent, - index, -}: { - agent: (typeof agents)[0]; - index: number; -}) { + CardDescription, + CardContent, + CardFooter, +} from "./card"; + +export interface Agent { + id: string; + name: string; + type: string; + personality: string; + risk_score: number; + total_value: number; + cash: number; + holdings_value: number; + roi_percent: number; + num_trades: number; + win_rate: number; // 0–1 from backend +} + +// Convert 0–1 → badge +function getRiskBadge(score: number) { + if (score >= 0.75) + return { text: "High Risk", class: "bg-red-500/10 text-red-400 ring-red-500/30" }; + if (score >= 0.45) + return { text: "Medium Risk", class: "bg-yellow-500/10 text-yellow-400 ring-yellow-500/30" }; + return { text: "Low Risk", class: "bg-green-500/10 text-green-400 ring-green-500/30" }; +} + +// Convert "NeuralNetTrader" → "Neural Net Trader" +function formatType(type: string) { + return type.replace(/([A-Z])/g, " $1").trim(); +} + +export function AgentCard({ agent, index }: { agent: Agent; index: number }) { + const risk = getRiskBadge(agent.risk_score); + const winRatePct = agent.win_rate * 100; + return ( - - -
- {agent.emoji} -
- + + + {/* HEADER */} + {agent.name} - - {agent.strategy} + + + {formatType(agent.type)} - - - {agent.badgeText} + {risk.text} +
- -
-

Win Rate

-
-

{agent.winRate}%

- {agent.trend === 'up' ? ( + {/* MAIN */} + + + {/* ROI */} +
+

ROI

+
+

= 0 ? "text-green-400" : "text-red-400" + }`} + > + {agent.roi_percent.toFixed(1)}% +

+ {agent.roi_percent >= 0 ? ( ) : ( )}
+
-
+ {/* Win Rate Bar */} +
+

Win Rate

+ +

+ {winRatePct.toFixed(0)}% +

+ +
+ + {/* Stats Grid */} +
+
+

Total Value

+

+ ${agent.total_value.toLocaleString()} +

+
+ +
+

Cash

+

+ ${agent.cash.toLocaleString()} +

+
+ +
+

Holdings

+

+ ${agent.holdings_value.toLocaleString()} +

+
+ +
+

Trades

+

+ {agent.num_trades} +

+
+
- + {/* FOOTER */} + Bet on Agent + ); diff --git a/frontend/app/home/components/ActiveBets.tsx b/frontend/app/home/components/ActiveBets.tsx index 1b7101c..309d28b 100644 --- a/frontend/app/home/components/ActiveBets.tsx +++ b/frontend/app/home/components/ActiveBets.tsx @@ -1,50 +1,76 @@ 'use client'; -import { useState } from 'react'; -import { mockBets } from '../data/mockBet'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Trophy, TrendingUp, XCircle, Clock, Zap } from 'lucide-react'; +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Trophy, TrendingUp, TrendingDown, XCircle, Clock, Zap } from "lucide-react"; + +import { mockBets } from "@/app/home/data/mockBets"; -// ADD THIS INTERFACE interface ActiveBetsProps { tournamentId: number; } -// UPDATE THIS LINE TO ACCEPT THE PROP -export default function ActiveBets({ tournamentId }: ActiveBetsProps) { - const [filter, setFilter] = useState<'active' | 'past'>('active'); +// ------------------------------ +// BACKEND FORMAT — DO NOT MODIFY +// ------------------------------ +export interface Bet { + id: string; + tournament_id: string; + user_address: string; + agent_id: string; + amount: number; + direction: "up" | "down"; + settled: boolean; + payout: number | null; + created_at: string; +} - // Filter bets based on status AND tournament_id - const activeBets = mockBets.filter( - (b) => b.status === 'active' && b.tournament_id === tournamentId - ); - const pastBets = mockBets.filter( - (b) => - (b.status === 'won' || b.status === 'lost') && - b.tournament_id === tournamentId +// ------------------------------ +// UI-only enrichment +// ------------------------------ +interface EnrichedBet extends Bet { + status: "active" | "won" | "lost"; +} + +function getBetStatus(b: Bet): "active" | "won" | "lost" { + if (!b.settled) return "active"; + if (b.payout && b.payout > 0) return "won"; + return "lost"; +} + +const getAgentColor = (id: string) => + ({ + "1": "from-blue-500 to-cyan-500", + "2": "from-purple-500 to-pink-500", + "3": "from-green-500 to-emerald-500", + }[id] || "from-gray-500 to-slate-500"); + +export default function ActiveBets({ tournamentId }: ActiveBetsProps) { + const [filter, setFilter] = useState<"active" | "past">("active"); + + // ------------------------------ + // Enrich backend data for UI use + // ------------------------------ + const enriched: EnrichedBet[] = mockBets.map((b) => ({ + ...b, + status: getBetStatus(b), + })); + + const active = enriched.filter( + (b) => b.status === "active" && b.tournament_id === String(tournamentId) ); - const displayedBets = filter === 'active' ? activeBets : pastBets; + const past = enriched.filter( + (b) => b.status !== "active" && b.tournament_id === String(tournamentId) + ); - // 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'; - }; + const displayed = filter === "active" ? active : past; return ( -
- {/* Background glow */} -
+
{/* Header */} -
+
@@ -55,176 +81,124 @@ export default function ActiveBets({ tournamentId }: ActiveBetsProps) {
- {/* Total stats badge */}
- {displayedBets.length} Bets + {displayed.length} Bets
- {/* Filter Tabs */} -
+ {/* Tabs */} +
+
- {/* Bets Display - Card Grid */} -
- {displayedBets.length === 0 ? ( -
-
- -
-

No {filter} bets

-
- ) : ( -
- - - {displayedBets.map((bet, index) => ( - - {/* Hover glow effect */} -
- -
- {/* Top row: Agent info & Status */} -
-
- {/* Agent avatar */} -
- {bet.agent - .split(' ') - .map((w) => w[0]) - .join('')} -
-
-

- {bet.agent} -

-

- Tournament #{bet.tournament_id} -

-
-
- - {/* Status badge */} - {bet.status === 'active' && ( -
- - - Active - -
- )} - {bet.status === 'won' && ( -
- - - Won - -
- )} - {bet.status === 'lost' && ( -
- - - Lost - -
- )} -
+ {/* Bets List */} +
+ + {displayed.map((bet, index) => ( + + {/* TOP ROW */} +
+ + {/* Agent pill */} +
+
+ {bet.agent_id} +
- {/* Bottom row: Bet amount */} -
-

Bet Amount

-

- {bet.amount_eth} ETH -

+
+

+ Agent #{bet.agent_id} +

+ +
+ {bet.direction === "up" ? ( + + ) : ( + + )} + {bet.direction.toUpperCase()}
- - ))} - -
- )} -
- - {/* Summary footer for past bets */} - {filter === 'past' && pastBets.length > 0 && ( -
-
-
-
- - Won +
+ + {/* Status badge */} + {bet.status === "active" && ( +
+ + + Active + +
+ )} + + {bet.status === "won" && ( +
+ + + Won +{bet.payout} ETH + +
+ )} + + {bet.status === "lost" && ( +
+ + + Lost + +
+ )}
-

- {pastBets.filter((b) => b.status === 'won').length} -

-
- -
-
- - Lost + + {/* AMOUNT + TIMESTAMP */} +
+

Bet Amount

+

+ {bet.amount} ETH +

+ +

+ Placed: {new Date(bet.created_at).toLocaleString()} +

-

- {pastBets.filter((b) => b.status === 'lost').length} -

-
-
-
- )} - {/* Bottom accent line */} -
+ + ))} + +
); } diff --git a/frontend/app/home/components/AgentPositions.tsx b/frontend/app/home/components/AgentPositions.tsx index 338da48..f954a42 100644 --- a/frontend/app/home/components/AgentPositions.tsx +++ b/frontend/app/home/components/AgentPositions.tsx @@ -1,160 +1,111 @@ -'use client'; -import { mockPositions } from '../data/mockPositions'; -import { motion } from 'framer-motion'; -import { - Wallet, - TrendingUp, - TrendingDown, - PieChart, - Sparkles, -} from 'lucide-react'; +"use client"; -interface AgentPositionsProps { - tournamentId: number; +import { motion } from "framer-motion"; + +// BACKEND FORMAT — do not add fields +export interface Position { + agent_id: string; + tournament_id: string; + token: string; + amount: number; + current_value_usd: number; } -export default function AgentPositions({ tournamentId }: AgentPositionsProps) { - // Filter positions by tournament ID - const filteredPositions = mockPositions.filter( - (pos) => pos.tournament_id === tournamentId +// TEMP MOCK (will be replaced with real hook) +import { mockPositions } from "@/app/home/data/mockPositions"; + +export default function AgentPositions({ tournamentId }: { tournamentId: number }) { + // Filter for this tournament only + const positions: Position[] = mockPositions.filter( + (p) => String(p.tournament_id) === String(tournamentId) ); - // calculate total portfolio value for this tournament - const totalValue = filteredPositions.reduce( - (sum, pos) => sum + pos.current_value_usd, + // Total portfolio value + const totalValue = positions.reduce( + (sum, p) => sum + p.current_value_usd, 0 ); return ( - {/* background glow */} -
- - {/* header */} -
+ {/* HEADER */} +
- +
+

Agent Positions

Tournament #{tournamentId}

- - {/* view toggle */} - - -
- {/* positions list */} -
- {filteredPositions.length === 0 ? ( -
-

No positions for this tournament

-
+ {/* LIST */} +
+ {positions.length === 0 ? ( +

+ No positions for this tournament +

) : ( - filteredPositions.map((position, index) => { - const percentOfTotal = - totalValue > 0 - ? (position.current_value_usd / totalValue) * 100 - : 0; - const change = - (position.current_value_usd / (position.amount * 2000) - 1) * 100; // Mock change + positions.map((p, index) => { + const pct = + totalValue > 0 ? (p.current_value_usd / totalValue) * 100 : 0; return ( - {/* background gradient on hover */} -
- -
- {/* token info row */} -
-
- {/* token icon */} -
- {position.token[0]} -
- -
-

{position.token}

-

- {position.amount} {position.token} -

-
+ {/* TOKEN + VALUE */} +
+
+
+ {p.token[0]}
- {/* value and change */} -
-

- ${position.current_value_usd.toLocaleString()} +

+

{p.token}

+

+ {p.amount} {p.token}

-
= 0 ? 'text-green-400' : 'text-red-400' - }`} - > - {change >= 0 ? ( - - ) : ( - - )} - {Math.abs(change).toFixed(1)}% -
- {/* progress bar */} -
-
- - Portfolio allocation - - - {percentOfTotal.toFixed(1)}% - -
+

+ ${p.current_value_usd.toLocaleString()} +

+
-
- - {/* shimmer effect */} - - -
+ {/* ALLOCATION BAR */} +
+
+ Portfolio allocation + + {pct.toFixed(1)}% + +
+ +
+
@@ -163,32 +114,25 @@ export default function AgentPositions({ tournamentId }: AgentPositionsProps) { )}
- {/* total portfolio summary */} - {filteredPositions.length > 0 && ( -
-
+ {/* SUMMARY */} + {positions.length > 0 && ( +
+
-
- - - Total Portfolio Value - -
+ Total Portfolio Value +

${totalValue.toLocaleString()}

- {filteredPositions.length} assets + {positions.length} assets

)} - - {/* bottom accent line */} -
); } diff --git a/frontend/app/home/components/BettingOverview.tsx b/frontend/app/home/components/BettingOverview.tsx index 3305d20..04f1d76 100644 --- a/frontend/app/home/components/BettingOverview.tsx +++ b/frontend/app/home/components/BettingOverview.tsx @@ -1,25 +1,74 @@ -import { mockBets } from '../data/mockBet'; +'use client'; + +import { mockBets } from "../data/mockBets"; + +// Backend format (reference only) +// { +// id: string; +// tournament_id: string; +// user_address: string; +// agent_id: string; +// amount: number; +// direction: "up" | "down"; +// settled: boolean; +// payout: number | null; +// created_at: string; +// } export default function BettingOverview() { + const bets = mockBets; // later: useUserBets() + return (

Your Bets

- {mockBets.length === 0 ? ( + {bets.length === 0 ? (

You haven't placed any bets yet.

) : (
- {mockBets.map((b, idx) => ( -
-

Agent: {b.agent}

-

- Amount: {b.amount_eth} ETH -

-

- Tournament: {b.tournament_id} -

-
- ))} + {bets.map((bet) => { + const isWin = bet.payout !== null && bet.payout > 0; + const isActive = bet.settled === false; + + return ( +
+ {/* LEFT SIDE — bet info */} +
+

+ Agent #{bet.agent_id} +

+ +

+ Amount: {bet.amount} ETH +

+ +

+ Direction: {bet.direction.toUpperCase()} +

+ +

+ Placed: {new Date(bet.created_at).toLocaleString()} +

+
+ + {/* RIGHT SIDE — status */} +

+ {isActive ? "ACTIVE" : isWin ? "WON" : "LOST"} +

+
+ ); + })}
)}
diff --git a/frontend/app/home/components/CandleChart.tsx b/frontend/app/home/components/CandleChart.tsx index 23d5434..aeeb248 100644 --- a/frontend/app/home/components/CandleChart.tsx +++ b/frontend/app/home/components/CandleChart.tsx @@ -1,298 +1,302 @@ '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 type { + CandlestickData, + HistogramData, + LineData, + Time, +} from 'lightweight-charts'; + 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; -} + mockOhlcv, + type OhlcCandle, +} from '@/app/home/data/mockOhlcv'; + +/* ---------------------------------------- + TYPES +----------------------------------------- */ + +type Timeframe = '1m' | '5m' | '15m' | '1h' | '4h' | '1d'; +type IndicatorKey = 'volume' | 'sma' | 'ema' | 'vwap'; interface CandleChartProps { tournamentId: number; } -/* --------------------- 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), + })) + .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, -}; - -/* --------------------------- Component --------------------------- */ +/* ---------------------------------------- + INDICATORS +----------------------------------------- */ + +function calculateSMA(data: OhlcCandle[], period = 20): LineData