From c305ea5d3b3b39dd27d1f60a45ee7e2316cfd511 Mon Sep 17 00:00:00 2001 From: MarkLo127 Date: Wed, 11 Mar 2026 18:38:42 +0800 Subject: [PATCH] --- CLAUDE.md | 87 ++++++++++ backend/app/api/user.py | 38 ++++- backend/app/db/database.py | 6 + frontend/app/history/page.tsx | 290 +++++++++++++++++++++++----------- frontend/lib/i18n/en.ts | 1 + frontend/lib/i18n/zh-TW.ts | 1 + frontend/lib/reports-db.ts | 45 ++++++ frontend/lib/user-api.ts | 26 ++- 8 files changed, 390 insertions(+), 104 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f90cecbd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TradingAgentsX is a multi-agent AI trading analysis system that simulates real-world investment firm operations. It uses LangGraph to orchestrate 12 specialized AI agents (analysts, researchers, traders, risk managers) that collaborate through structured debate workflows to generate trading decisions. + +**Stack**: Python 3.10+ backend (FastAPI) + Next.js 16 frontend (React 19, TypeScript) + +## Build & Run Commands + +### Backend (FastAPI) +```bash +pip install -e . # Install package in editable mode +pip install -r backend/requirements.txt # Install backend dependencies +python -m backend # Run server (default: localhost:8000) +python -m backend --host 0.0.0.0 --port 8000 --reload true # With options +``` + +### Frontend (Next.js) +```bash +bun install --cwd frontend # Install dependencies +bun run --cwd frontend dev # Development server (localhost:3000) +bun run --cwd frontend build # Production build +bun run --cwd frontend lint # ESLint +``` + +### Docker +```bash +cp .env.example .env +docker compose up -d --build +``` + +### CLI +```bash +tradingagents # After pip install -e . +python main.py # Standalone analysis +``` + +## Architecture + +### Agent System (`tradingagents/`) +- **12 AI agents** organized into teams using LangGraph orchestration +- `agents/analysts/` - Market, News, Social Media, Fundamentals analysts +- `agents/researchers/` - Bull & Bear case researchers + Research Manager +- `agents/risk_mgmt/` - Aggressive, Conservative, Neutral debaters + Risk Manager +- `agents/trader/` - Final trading decision aggregator +- `graph/trading_graph.py` - Main `TradingAgentsXGraph` class orchestrating the workflow +- `dataflows/` - Data vendor integrations (Yahoo Finance, Alpha Vantage, FinMind, Google News, Reddit) + +### Backend (`backend/`) +- `app/main.py` - FastAPI app with security middleware (rate limiting 30/min, security headers) +- `app/api/routes.py` - `/api/analyze`, `/api/task/{id}`, `/api/chat`, `/api/download` +- `app/api/auth.py` - Google OAuth flow +- `app/services/trading_service.py` - Orchestrates agent graph execution +- `app/services/task_manager.py` - Async task lifecycle +- `app/services/pdf_generator.py` - Report generation (complex, 61KB) + +### Frontend (`frontend/`) +- `app/` - Next.js App Router pages (analysis, history, auth) +- `components/AgentFlowDiagram.tsx` - 12-agent visualization +- `lib/crypto.ts` - AES-GCM encryption for API keys (BYOK model) +- `lib/reports-db.ts` - IndexedDB wrapper for local report storage +- `contexts/` - AuthContext, LanguageContext + +## Key Entry Points + +- **Backend**: `python -m backend` → `backend/__main__.py` → `backend/app/main.py` +- **Frontend**: `bun run dev` → `frontend/app/layout.tsx` → `frontend/app/page.tsx` +- **Core Logic**: `TradingAgentsXGraph.propagate(ticker, date)` in `tradingagents/graph/trading_graph.py` + +## Environment Variables + +Required in `.env` (see `.env.example`): +- LLM API keys: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, etc. +- Data APIs: `ALPHA_VANTAGE_API_KEY`, `FINMIND_API_KEY` +- Auth: `JWT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` +- Config: `NEXT_PUBLIC_API_URL`, `CORS_ORIGINS`, `FRONTEND_URL` + +## Key Dependencies + +- **Agent Framework**: LangGraph 0.4.8+, LangChain 0.1.0+ +- **LLM Providers**: langchain-openai, langchain-anthropic, langchain-google-genai +- **Data**: yfinance, pandas, polars, ChromaDB (vector store) +- **Backend**: FastAPI, SQLAlchemy 2.0+, Redis, asyncpg +- **Frontend**: Next.js 16, React 19, Tailwind CSS 4, shadcn/ui, Dexie.js (IndexedDB) diff --git a/backend/app/api/user.py b/backend/app/api/user.py index c58fe346..45a32f9e 100644 --- a/backend/app/api/user.py +++ b/backend/app/api/user.py @@ -151,16 +151,38 @@ async def update_settings( @router.get("/reports", response_model=List[ReportResponse]) async def get_reports( user: User = Depends(get_current_user_required), - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + market_type: Optional[str] = None, + language: Optional[str] = None, + limit: int = 100, + offset: int = 0 ): - """Get all user's reports""" - result = await db.execute( - select(Report) - .where(Report.user_id == user.id) - .order_by(Report.created_at.desc()) - ) + """Get user's reports with optional filtering and pagination + + Args: + market_type: Filter by market type (us, twse, tpex) + language: Filter by language (en, zh-TW) + limit: Maximum number of reports to return (default 100, max 500) + offset: Number of reports to skip for pagination + """ + # Cap limit at 500 to prevent memory issues + limit = min(limit, 500) + + # Build query with filters + query = select(Report).where(Report.user_id == user.id) + + if market_type: + query = query.where(Report.market_type == market_type) + + if language: + query = query.where(Report.language == language) + + # Order by created_at DESC and apply pagination + query = query.order_by(Report.created_at.desc()).offset(offset).limit(limit) + + result = await db.execute(query) reports = result.scalars().all() - + return [ ReportResponse( id=str(r.id), diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 66dd2b6a..1830562d 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -70,6 +70,12 @@ async def init_db(): # Add indexes to optimize queries await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_id ON reports (user_id);")) await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_created_at ON reports (created_at);")) + # Add composite index for common query pattern (user_id + created_at DESC) + await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_created ON reports (user_id, created_at DESC);")) + # Add index for language filtering + await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_language ON reports (language);")) + # Add composite index for user + market_type + language queries + await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_reports_user_market_lang ON reports (user_id, market_type, language);")) except Exception as e: print(f"Skipping manual migration (might be SQLite or syntax not supported): {e}") diff --git a/frontend/app/history/page.tsx b/frontend/app/history/page.tsx index 27a1b678..32b35580 100644 --- a/frontend/app/history/page.tsx +++ b/frontend/app/history/page.tsx @@ -40,9 +40,9 @@ import { import { getReportsByMarketType, deleteReport, - getReportCountByMarketType, deleteReports, getAllReports, + bulkSaveReports, type SavedReport, } from "@/lib/reports-db"; import { @@ -406,6 +406,9 @@ export default function HistoryPage() { ); const [deleting, setDeleting] = useState(false); + // Sync state + const [syncing, setSyncing] = useState(false); + // Auto-sync tracking ref const hasAutoSyncedRef = useRef(false); const cloudReportsPromiseRef = useRef | null>(null); @@ -430,76 +433,66 @@ export default function HistoryPage() { loadCounts(); }, [isAuthenticated, locale]); - // Auto-sync local reports to cloud when page loads (if authenticated) - useEffect(() => { - const autoSync = async () => { - // Only sync once per session, and only if authenticated - if ( - hasAutoSyncedRef.current || - !isAuthenticated || - !isCloudSyncEnabled() - ) { - return; + // Bidirectional sync: upload local to cloud AND download cloud to local + const performBidirectionalSync = async (isInitialSync = false) => { + if (!isAuthenticated || !isCloudSyncEnabled()) { + return; + } + + // For initial sync, only run once per session + if (isInitialSync && hasAutoSyncedRef.current) { + return; + } + + if (isInitialSync) { + hasAutoSyncedRef.current = true; + } + + try { + // First auto-clean local duplicates that might exist from older flawed versions + try { + const allLocal = await getAllReports(); + const seenSignatures = new Set(); + const idsToDelete: number[] = []; + + for (const report of allLocal) { + const signature = getReportSignature(report as any); + if (seenSignatures.has(signature)) { + if (report.id) idsToDelete.push(report.id); + } else { + seenSignatures.add(signature); + } + } + + if (idsToDelete.length > 0) { + console.log(`🧹 Found ${idsToDelete.length} duplicate local reports, cleaning...`); + await deleteReports(idsToDelete); + } + } catch (err) { + console.error("Failed to cleanup local duplicates:", err); } - hasAutoSyncedRef.current = true; + // Get all local reports (re-fetch after cleanup) + const [usLocal, twseLocal, tpexLocal] = await Promise.all([ + getReportsByMarketType("us"), + getReportsByMarketType("twse"), + getReportsByMarketType("tpex"), + ]); + const allLocal = [...usLocal, ...twseLocal, ...tpexLocal]; - try { - // First auto-clean local duplicates that might exist from older flawed versions - try { - const allLocal = await getAllReports(); - const seenSignatures = new Set(); - const idsToDelete: number[] = []; + // Get cloud reports + const cloudReports = await fetchCloudReportsCached(true); // Force refresh + const cloudKeys = new Set(cloudReports.map((r) => getReportSignature(r))); + const localKeys = new Set(allLocal.map((r) => getReportSignature(r))); - for (const report of allLocal) { - const signature = getReportSignature(report as any); - if (seenSignatures.has(signature)) { - if (report.id) idsToDelete.push(report.id); - } else { - seenSignatures.add(signature); - } - } + // === UPLOAD: Local -> Cloud === + const toUpload = allLocal.filter( + (r) => !cloudKeys.has(getReportSignature(r)), + ); - if (idsToDelete.length > 0) { - console.log(`🧹 Found ${idsToDelete.length} duplicate local reports from flawed storage, cleaning them up...`); - await deleteReports(idsToDelete); - } - } catch (err) { - console.error("Failed to cleanup local duplicates:", err); - } - - // Get all local reports (re-fetch after cleanup) - const [usLocal, twseLocal, tpexLocal] = await Promise.all([ - getReportsByMarketType("us"), - getReportsByMarketType("twse"), - getReportsByMarketType("tpex"), - ]); - const allLocal = [...usLocal, ...twseLocal, ...tpexLocal]; - - if (allLocal.length === 0) return; - - // Get cloud reports to check for duplicates - const cloudReports = await fetchCloudReportsCached(); - const cloudKeys = new Set( - cloudReports.map((r) => getReportSignature(r)), - ); - - // Find local-only reports to upload - const toUpload = allLocal.filter( - (r) => !cloudKeys.has(getReportSignature(r)), - ); - - if (toUpload.length === 0) { - console.log("☁️ Auto-sync: All reports already in cloud"); - return; - } - - console.log( - `☁️ Auto-sync: Uploading ${toUpload.length} local reports to cloud...`, - ); - - // Upload each report silently - let success = 0; + if (toUpload.length > 0) { + console.log(`☁️ Sync: Uploading ${toUpload.length} local reports to cloud...`); + let uploadSuccess = 0; for (const report of toUpload) { try { const cloudId = await saveCloudReport({ @@ -507,31 +500,107 @@ export default function HistoryPage() { market_type: report.market_type, analysis_date: report.analysis_date, result: report.result, + language: report.language || detectReportLanguage(report.result?.reports), }); - if (cloudId) success++; + if (cloudId) uploadSuccess++; } catch (e) { // Silently continue on error } } - - if (success > 0) { - console.log(`☁️ Auto-sync: Successfully uploaded ${success} reports`); - // Reload to show updated data - await fetchCloudReportsCached(true); - await loadReports(); - await loadCounts(); + if (uploadSuccess > 0) { + console.log(`☁️ Sync: Uploaded ${uploadSuccess} reports to cloud`); } - } catch (error) { - console.error("☁️ Auto-sync failed:", error); + } + + // === DOWNLOAD: Cloud -> Local === + const toDownload = cloudReports.filter( + (r) => !localKeys.has(getReportSignature(r)), + ); + + if (toDownload.length > 0) { + console.log(`☁️ Sync: Downloading ${toDownload.length} cloud reports to local...`); + const reportsToSave = toDownload.map((r) => ({ + ticker: r.ticker, + market_type: r.market_type as "us" | "twse" | "tpex", + analysis_date: r.analysis_date, + saved_at: parseUTCDate(r.created_at), + result: r.result, + language: (r.language || detectReportLanguage(r.result?.reports)) as "en" | "zh-TW" | undefined, + })); + + const savedCount = await bulkSaveReports(reportsToSave); + if (savedCount > 0) { + console.log(`☁️ Sync: Downloaded ${savedCount} reports to local`); + } + } + + // === DELETE SYNC: Remove local reports that were deleted from cloud === + // Only delete reports that are old enough (> 2 minutes) to avoid deleting newly saved reports + const TWO_MINUTES_AGO = Date.now() - 2 * 60 * 1000; + const toDeleteLocal = allLocal.filter((localReport) => { + // Don't delete recently saved reports (might not be uploaded yet) + const savedTime = new Date(localReport.saved_at).getTime(); + if (savedTime > TWO_MINUTES_AGO) { + return false; + } + // If local report is not in cloud, it was likely deleted on another device + return !cloudKeys.has(getReportSignature(localReport)); + }); + + if (toDeleteLocal.length > 0) { + console.log(`☁️ Sync: Removing ${toDeleteLocal.length} locally cached reports that were deleted from cloud...`); + const idsToDelete = toDeleteLocal + .map((r) => r.id) + .filter((id): id is number => id !== undefined); + if (idsToDelete.length > 0) { + await deleteReports(idsToDelete); + console.log(`☁️ Sync: Removed ${idsToDelete.length} local reports`); + } + } + + // Reload UI if any changes + if (toUpload.length > 0 || toDownload.length > 0 || toDeleteLocal.length > 0) { + await loadReports(); + await loadCounts(); + } else { + console.log("☁️ Sync: Already in sync"); + } + } catch (error) { + console.error("☁️ Sync failed:", error); + } + }; + + // Initial sync when page loads (if authenticated) + useEffect(() => { + performBidirectionalSync(true); + }, [isAuthenticated]); + + // Re-sync when page becomes visible (handles cross-device changes) + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && isAuthenticated && isCloudSyncEnabled()) { + console.log("☁️ Page visible, checking for updates..."); + performBidirectionalSync(false); } }; - autoSync(); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; }, [isAuthenticated]); const loadReports = async () => { setLoading(true); try { + // Helper to filter reports by current UI language + const filterByLang = (reports: SavedReport[]) => { + return reports.filter((report) => { + const reportLang = report.language || detectReportLanguage(report.result?.reports); + return reportLang === locale; + }); + }; + // Always load local IndexedDB reports first const localData = await getReportsByMarketType(activeTab); @@ -574,18 +643,25 @@ export default function HistoryPage() { new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(), ); - setReports(merged); + // Filter by current UI language before display + setReports(filterByLang(merged)); setIsCloudData(true); return; } } - setReports(localData); + // Filter local data by language before display + setReports(filterByLang(localData)); setIsCloudData(false); } catch (error) { console.error("Failed to load reports:", error); const data = await getReportsByMarketType(activeTab); - setReports(data as SavedReport[]); + // Filter by language on error fallback too + const filtered = data.filter((report) => { + const reportLang = report.language || detectReportLanguage(report.result?.reports); + return reportLang === locale; + }); + setReports(filtered as SavedReport[]); setIsCloudData(false); } finally { setLoading(false); @@ -594,9 +670,14 @@ export default function HistoryPage() { const loadCounts = async () => { try { - // Helper to filter reports by language + // Helper to filter reports by language (matches current UI locale) const filterByLanguage = (reports: SavedReport[]) => { - return reports; + return reports.filter((report) => { + // Get report language - use stored value or detect from content + const reportLang = report.language || detectReportLanguage(report.result?.reports); + // Match against current locale + return reportLang === locale; + }); }; if (isAuthenticated && isCloudSyncEnabled()) { @@ -672,6 +753,20 @@ export default function HistoryPage() { } }; + // Handle refresh button - performs full sync if authenticated + const handleRefresh = async () => { + if (isAuthenticated && isCloudSyncEnabled()) { + setSyncing(true); + try { + await performBidirectionalSync(false); + } finally { + setSyncing(false); + } + } else { + await loadReports(); + } + }; + const handleViewReport = (report: SavedReport) => { // Set the context with the saved report data setAnalysisResult(report.result); @@ -701,17 +796,26 @@ export default function HistoryPage() { } // 2. Always try to delete from local IndexedDB as well - // Find exact matching local report by signature + // Use strict matching: ticker + date + market_type + language try { const localReports = await getReportsByMarketType( reportToDelete.market_type, ); - const targetSignature = getReportSignature(reportToDelete); - const matchingLocal = localReports.find( - (r) => getReportSignature(r) === targetSignature - ); + // Get language of report to delete (use stored or detect) + const targetLang = reportToDelete.language || detectReportLanguage(reportToDelete.result?.reports); + + // Find matching report with same ticker, date, market, AND language + const matchingLocal = localReports.find((r) => { + if (r.ticker !== reportToDelete.ticker) return false; + if (r.analysis_date !== reportToDelete.analysis_date) return false; + if (r.market_type !== reportToDelete.market_type) return false; + // Match language (detect if not stored) + const localLang = r.language || detectReportLanguage(r.result?.reports); + return localLang === targetLang; + }); + if (matchingLocal && matchingLocal.id) { - console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id); + console.log("🗑️ Deleting from local IndexedDB:", matchingLocal.id, "language:", targetLang); await deleteReport(matchingLocal.id); } } catch (localError) { @@ -900,19 +1004,19 @@ export default function HistoryPage() { ).map((marketType) => (
- {/* Refresh button */} + {/* Refresh/Sync button */}
diff --git a/frontend/lib/i18n/en.ts b/frontend/lib/i18n/en.ts index d372fe0f..f6049c53 100644 --- a/frontend/lib/i18n/en.ts +++ b/frontend/lib/i18n/en.ts @@ -588,6 +588,7 @@ export const en = { cancel: "Cancel", deleting: "Deleting...", confirmDeleteBtn: "Confirm Delete", + syncing: "Syncing...", }, // Errors diff --git a/frontend/lib/i18n/zh-TW.ts b/frontend/lib/i18n/zh-TW.ts index d63ab88f..c776525b 100644 --- a/frontend/lib/i18n/zh-TW.ts +++ b/frontend/lib/i18n/zh-TW.ts @@ -470,6 +470,7 @@ export const zhTW = { cancel: "取消", deleting: "刪除中...", confirmDeleteBtn: "確認刪除", + syncing: "同步中...", }, // Errors diff --git a/frontend/lib/reports-db.ts b/frontend/lib/reports-db.ts index 89f9485c..0bde5760 100644 --- a/frontend/lib/reports-db.ts +++ b/frontend/lib/reports-db.ts @@ -136,6 +136,51 @@ export async function checkDuplicateReport( .first(); } +/** + * Check if a report exists by ticker, date, market type, and language + * Used for bidirectional sync to prevent duplicates + */ +export async function findExistingReport( + ticker: string, + analysis_date: string, + market_type: "us" | "twse" | "tpex", + language?: "en" | "zh-TW", +): Promise { + return await db.reports + .where("ticker") + .equals(ticker) + .and( + (report) => + report.analysis_date === analysis_date && + report.market_type === market_type && + report.language === language + ) + .first(); +} + +/** + * Bulk save reports to the database (for syncing from cloud) + * Skips reports that already exist locally + */ +export async function bulkSaveReports( + reports: Omit[] +): Promise { + let savedCount = 0; + for (const report of reports) { + const existing = await findExistingReport( + report.ticker, + report.analysis_date, + report.market_type, + report.language + ); + if (!existing) { + await db.reports.add(report as SavedReport); + savedCount++; + } + } + return savedCount; +} + /** * Clear all reports from the database (for logout) */ diff --git a/frontend/lib/user-api.ts b/frontend/lib/user-api.ts index 03f0b620..6d187049 100644 --- a/frontend/lib/user-api.ts +++ b/frontend/lib/user-api.ts @@ -73,13 +73,33 @@ export async function saveCloudSettings(settings: ApiSettings): Promise } /** - * Fetch all reports from cloud + * Options for fetching cloud reports */ -export async function getCloudReports(): Promise { +interface GetCloudReportsOptions { + market_type?: "us" | "twse" | "tpex"; + language?: "en" | "zh-TW"; + limit?: number; + offset?: number; +} + +/** + * Fetch reports from cloud with optional filtering and pagination + */ +export async function getCloudReports(options?: GetCloudReportsOptions): Promise { if (!isCloudSyncEnabled()) return []; try { - const response = await fetch(`${API_BASE}/api/user/reports`, { + // Build query params + const params = new URLSearchParams(); + if (options?.market_type) params.set("market_type", options.market_type); + if (options?.language) params.set("language", options.language); + if (options?.limit) params.set("limit", options.limit.toString()); + if (options?.offset) params.set("offset", options.offset.toString()); + + const queryString = params.toString(); + const url = `${API_BASE}/api/user/reports${queryString ? `?${queryString}` : ""}`; + + const response = await fetch(url, { headers: getAuthHeaders(), });