diff --git a/README.md b/README.md index a8ab80b8..a4450d69 100644 --- a/README.md +++ b/README.md @@ -323,8 +323,11 @@ TRADINGAGENTS_RESULTS_DIR=./results ##### 2.4 啟動後端服務 ```bash -# 從專案根目錄執行 +# 從專案根目錄執行(開發模式,啟用 hot reload) python -m backend + +# 生產模式(停用 hot reload,避免任務丟失) +python -m backend --reload false ``` ✅ 後端服務成功啟動後,您可以訪問: diff --git a/backend/Dockerfile b/backend/Dockerfile index 8b98ff51..66be5fd9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,8 +26,8 @@ WORKDIR /app COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages COPY --from=builder /usr/local/bin /usr/local/bin -# Copy application code from backend directory -COPY backend/app ./backend/app +# Copy application code from backend directory (including __main__.py for python -m) +COPY backend ./backend # Copy tradingagents package from project root COPY tradingagents ./tradingagents @@ -40,4 +40,5 @@ EXPOSE 8000 # Run the application # Run the application using the PORT environment variable (required by Railway) -CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] +# Using --reload false to prevent hot-reload issues in production +CMD ["sh", "-c", "python -m backend --reload false --port ${PORT:-8000}"] diff --git a/backend/__main__.py b/backend/__main__.py index 362f39cf..56b4977e 100644 --- a/backend/__main__.py +++ b/backend/__main__.py @@ -1,10 +1,15 @@ """ Backend module entry point Run with: python -m backend +Options: + --reload / --reload false Enable/disable hot reload (default: true for dev) + --port PORT Server port (default: 8000) + --host HOST Server host (default: 0.0.0.0) """ import uvicorn import os import sys +import argparse from pathlib import Path # Add parent directory to path to import tradingagents @@ -13,25 +18,49 @@ if parent_dir not in sys.path: sys.path.insert(0, parent_dir) +def parse_args(): + """Parse command line arguments""" + parser = argparse.ArgumentParser(description="TradingAgentsX Backend Server") + parser.add_argument( + "--host", + type=str, + default=os.getenv("BACKEND_HOST", "0.0.0.0"), + help="Server host (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", + type=int, + default=int(os.getenv("PORT", os.getenv("BACKEND_PORT", "8000"))), + help="Server port (default: 8000)" + ) + parser.add_argument( + "--reload", + type=str, + default=os.getenv("BACKEND_RELOAD", "true"), + help="Enable hot reload (true/false, default: true)" + ) + return parser.parse_args() + + def main(): """Start the FastAPI server""" - # Get host and port from environment variables with defaults - host = os.getenv("BACKEND_HOST", "0.0.0.0") - port = int(os.getenv("BACKEND_PORT", "8000")) - reload = os.getenv("BACKEND_RELOAD", "true").lower() == "true" + args = parse_args() + + # Parse reload flag (support both boolean-style and string) + reload = args.reload.lower() in ("true", "1", "yes") if isinstance(args.reload, str) else args.reload print(f"🚀 Starting TradingAgentsX Backend Server...") - print(f"📍 Host: {host}") - print(f"🔌 Port: {port}") + print(f"📍 Host: {args.host}") + print(f"🔌 Port: {args.port}") print(f"🔄 Reload: {reload}") - print(f"\n📖 API Documentation: http://localhost:{port}/docs") - print(f"📊 Health Check: http://localhost:{port}/api/health\n") + print(f"\n📖 API Documentation: http://localhost:{args.port}/docs") + print(f"📊 Health Check: http://localhost:{args.port}/api/health\n") # Start uvicorn server uvicorn.run( "backend.app.main:app", # Use full module path - host=host, - port=port, + host=args.host, + port=args.port, reload=reload, log_level="info", ) diff --git a/frontend/app/analysis/page.tsx b/frontend/app/analysis/page.tsx index f3cfe0b4..34d9433e 100644 --- a/frontend/app/analysis/page.tsx +++ b/frontend/app/analysis/page.tsx @@ -14,7 +14,7 @@ import type { AnalysisRequest } from "@/lib/types"; export default function AnalysisPage() { const router = useRouter(); - const { setAnalysisResult, setTaskId } = useAnalysisContext(); + const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext(); const { runAnalysis, loading, error, result, taskId } = useAnalysis(); // 當分析完成時自動跳轉到結果頁面 @@ -30,6 +30,10 @@ export default function AnalysisPage() { const handleSubmit = async (data: AnalysisRequest) => { try { + // Store the market type for later use when saving the report + if (data.market_type) { + setMarketType(data.market_type); + } await runAnalysis(data); } catch (err) { // Error is handled by the hook diff --git a/frontend/app/analysis/results/page.tsx b/frontend/app/analysis/results/page.tsx index e8b5a7be..16f38e25 100644 --- a/frontend/app/analysis/results/page.tsx +++ b/frontend/app/analysis/results/page.tsx @@ -10,7 +10,8 @@ import { DownloadReports } from "@/components/analysis/DownloadReports"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { ChevronLeft } from "lucide-react"; +import { ChevronLeft, Save, Check, AlertCircle } from "lucide-react"; +import { saveReport, checkDuplicateReport } from "@/lib/reports-db"; const ANALYSTS = [ // === 分析師團隊 === @@ -101,8 +102,13 @@ const getNestedValue = (obj: any, path: string) => { export default function AnalysisResultsPage() { const router = useRouter(); - const { analysisResult, taskId } = useAnalysisContext(); + const { analysisResult, taskId, marketType } = useAnalysisContext(); const [selectedAnalyst, setSelectedAnalyst] = useState("market"); + + // Save report states + const [saving, setSaving] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + const [saveError, setSaveError] = useState(null); // 如果沒有結果,重定向到分析頁面 useEffect(() => { @@ -111,6 +117,46 @@ export default function AnalysisResultsPage() { } }, [analysisResult, router]); + // Handle save report + const handleSaveReport = async () => { + if (!analysisResult) return; + + setSaving(true); + setSaveError(null); + setSaveSuccess(false); + + try { + // Check for duplicate + const duplicate = await checkDuplicateReport( + analysisResult.ticker, + analysisResult.analysis_date + ); + + if (duplicate) { + setSaveError("此報告已存在(相同股票代碼與分析日期)"); + setSaving(false); + return; + } + + await saveReport( + analysisResult.ticker, + marketType, + analysisResult.analysis_date, + analysisResult, + taskId || undefined + ); + + setSaveSuccess(true); + // Reset success message after 3 seconds + setTimeout(() => setSaveSuccess(false), 3000); + } catch (error) { + console.error("Save report error:", error); + setSaveError("儲存失敗,請稍後再試"); + } finally { + setSaving(false); + } + }; + if (!analysisResult) { return (
@@ -143,14 +189,52 @@ export default function AnalysisResultsPage() { 分析日期:{analysisResult.analysis_date}

- +
+ {/* Save success/error feedback */} + {saveSuccess && ( + + + 已儲存 + + )} + {saveError && ( + + + {saveError} + + )} + + {/* Save Report Button */} + + + +
{/* 分析師選擇 Tabs */} diff --git a/frontend/app/history/page.tsx b/frontend/app/history/page.tsx new file mode 100644 index 00000000..563164b5 --- /dev/null +++ b/frontend/app/history/page.tsx @@ -0,0 +1,371 @@ +/** + * History page - browse saved analysis reports + */ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { format } from "date-fns"; +import { zhTW } from "date-fns/locale"; +import { useAnalysisContext } from "@/context/AnalysisContext"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, + CardFooter, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Trash2, Eye, RefreshCw, TrendingUp } from "lucide-react"; +import { + getReportsByMarketType, + deleteReport, + getReportCountByMarketType, + type SavedReport, +} from "@/lib/reports-db"; + +// Market type labels +const MARKET_LABELS = { + us: { label: "🇺🇸 美股", description: "美國股市分析報告" }, + twse: { label: "🇹🇼 上市", description: "台灣證券交易所上市股票" }, + tpex: { label: "🇹🇼 上櫃/興櫃", description: "台灣櫃買中心上櫃/興櫃股票" }, +}; + +// Helper function to extract decision from trader report +const extractDecisionFromReport = (report: SavedReport): { action: string; color: string } => { + // First try the decision.action field + if (report.result.decision?.action) { + const action = report.result.decision.action; + const actionLower = action.toLowerCase(); + const color = actionLower.includes("buy") + ? "text-green-600" + : actionLower.includes("sell") + ? "text-red-600" + : "text-yellow-600"; + return { action, color }; + } + + // Fallback: try to extract from trader_investment_plan + const traderReport = report.result.reports?.trader_investment_plan; + if (traderReport) { + const text = traderReport.toLowerCase(); + + // Look for Chinese decision keywords first + if (text.includes("最終交易提案:買入") || text.includes("建議:買入")) { + return { action: "買入", color: "text-green-600" }; + } + if (text.includes("最終交易提案:賣出") || text.includes("建議:賣出")) { + return { action: "賣出", color: "text-red-600" }; + } + if (text.includes("最終交易提案:持有") || text.includes("建議:持有")) { + return { action: "持有", color: "text-yellow-600" }; + } + + // Look for English keywords + if (text.includes("buy") && !text.includes("sell")) { + return { action: "買入 (BUY)", color: "text-green-600" }; + } + if (text.includes("sell") && !text.includes("buy")) { + return { action: "賣出 (SELL)", color: "text-red-600" }; + } + if (text.includes("hold")) { + return { action: "持有 (HOLD)", color: "text-yellow-600" }; + } + } + + // Fallback: try final_trade_decision + const finalDecision = report.result.reports?.final_trade_decision; + if (finalDecision) { + const text = finalDecision.toLowerCase(); + if (text.includes("buy") || text.includes("買入")) { + return { action: "買入", color: "text-green-600" }; + } + if (text.includes("sell") || text.includes("賣出")) { + return { action: "賣出", color: "text-red-600" }; + } + if (text.includes("hold") || text.includes("持有")) { + return { action: "持有", color: "text-yellow-600" }; + } + } + + return { action: "N/A", color: "text-gray-500" }; +}; + +export default function HistoryPage() { + const router = useRouter(); + const { setAnalysisResult, setTaskId, setMarketType } = useAnalysisContext(); + + const [activeTab, setActiveTab] = useState<"us" | "twse" | "tpex">("us"); + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [counts, setCounts] = useState({ us: 0, twse: 0, tpex: 0 }); + + // Delete confirmation dialog + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [reportToDelete, setReportToDelete] = useState( + null + ); + const [deleting, setDeleting] = useState(false); + + // Load reports when tab changes + useEffect(() => { + loadReports(); + }, [activeTab]); + + // Load counts on mount + useEffect(() => { + loadCounts(); + }, []); + + const loadReports = async () => { + setLoading(true); + try { + const data = await getReportsByMarketType(activeTab); + setReports(data); + } catch (error) { + console.error("Failed to load reports:", error); + } finally { + setLoading(false); + } + }; + + const loadCounts = async () => { + try { + const data = await getReportCountByMarketType(); + setCounts(data); + } catch (error) { + console.error("Failed to load counts:", error); + } + }; + + const handleViewReport = (report: SavedReport) => { + // Set the context with the saved report data + setAnalysisResult(report.result); + setTaskId(report.task_id || null); + setMarketType(report.market_type); + // Navigate to results page + router.push("/analysis/results"); + }; + + const handleDeleteClick = (report: SavedReport) => { + setReportToDelete(report); + setDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!reportToDelete?.id) return; + + setDeleting(true); + try { + await deleteReport(reportToDelete.id); + // Refresh reports and counts + await loadReports(); + await loadCounts(); + setDeleteDialogOpen(false); + setReportToDelete(null); + } catch (error) { + console.error("Failed to delete report:", error); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+
+ {/* Header */} +
+
+

+ 歷史報告 +

+

+ 瀏覽已儲存的分析報告 +

+
+ + {/* Market Type Tabs */} + setActiveTab(v as typeof activeTab)} + className="w-full animate-slide-up animate-delay-200" + > + + {(Object.keys(MARKET_LABELS) as Array).map( + (key) => ( + + {MARKET_LABELS[key].label} + + {counts[key]} + + + ) + )} + + + {(Object.keys(MARKET_LABELS) as Array).map( + (marketType) => ( + +
+ {/* Refresh button */} +
+ +
+ + {/* Report List */} + {loading ? ( +
+ +

載入中...

+
+ ) : reports.length === 0 ? ( + + + +

+ 尚無{MARKET_LABELS[marketType].label}的分析報告 +

+

+ 執行分析後,可在結果頁面儲存報告 +

+ +
+
+ ) : ( +
+ {reports.map((report) => ( + + + + + {report.ticker} + + + {MARKET_LABELS[report.market_type].label} + + + + 分析日期:{report.analysis_date} + + + +

+ 儲存時間: + {format( + new Date(report.saved_at), + "yyyy/MM/dd HH:mm", + { locale: zhTW } + )} +

+ {(() => { + const decision = extractDecisionFromReport(report); + return ( +

+ 決策: + + {decision.action} + +

+ ); + })()} +
+ + + + +
+ ))} +
+ )} +
+
+ ) + )} +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + 確認刪除 + + 確定要刪除 {reportToDelete?.ticker} 於{" "} + {reportToDelete?.analysis_date} 的分析報告嗎? +
+ 此操作無法復原。 +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx index 78fcd2db..997ba9b2 100644 --- a/frontend/components/layout/Header.tsx +++ b/frontend/components/layout/Header.tsx @@ -29,6 +29,12 @@ export function Header() { > 分析 + + 歷史報告 + diff --git a/frontend/context/AnalysisContext.tsx b/frontend/context/AnalysisContext.tsx index 2826fec8..25689515 100644 --- a/frontend/context/AnalysisContext.tsx +++ b/frontend/context/AnalysisContext.tsx @@ -8,6 +8,8 @@ interface AnalysisContextType { setAnalysisResult: (result: AnalysisResponse | null) => void; taskId: string | null; setTaskId: (taskId: string | null) => void; + marketType: "us" | "twse" | "tpex"; + setMarketType: (type: "us" | "twse" | "tpex") => void; } const AnalysisContext = createContext( @@ -19,9 +21,17 @@ export function AnalysisProvider({ children }: { children: ReactNode }) { null ); const [taskId, setTaskId] = useState(null); + const [marketType, setMarketType] = useState<"us" | "twse" | "tpex">("us"); return ( - + {children} ); diff --git a/frontend/lib/reports-db.ts b/frontend/lib/reports-db.ts new file mode 100644 index 00000000..d508276a --- /dev/null +++ b/frontend/lib/reports-db.ts @@ -0,0 +1,134 @@ +/** + * IndexedDB database for storing analysis reports + * Uses Dexie.js for a cleaner API + */ + +import Dexie, { type Table } from 'dexie'; +import type { AnalysisResponse } from './types'; + +// Saved report interface +export interface SavedReport { + id?: number; // Auto-generated primary key + ticker: string; // Stock ticker symbol + market_type: "us" | "twse" | "tpex"; // Market type + analysis_date: string; // Analysis date (YYYY-MM-DD) + saved_at: Date; // Save timestamp + task_id?: string; // Original task ID + result: AnalysisResponse; // Full analysis result +} + +// Database class extending Dexie +class ReportsDatabase extends Dexie { + reports!: Table; + + constructor() { + super('TradingAgentsReports'); + this.version(1).stores({ + // Define indexes: ++id = auto-increment, others are indexed fields + reports: '++id, ticker, market_type, analysis_date, saved_at' + }); + } +} + +// Database singleton instance +const db = new ReportsDatabase(); + +/** + * Save a report to the database + */ +export async function saveReport( + ticker: string, + market_type: "us" | "twse" | "tpex", + analysis_date: string, + result: AnalysisResponse, + task_id?: string +): Promise { + const report: SavedReport = { + ticker, + market_type, + analysis_date, + saved_at: new Date(), + task_id, + result, + }; + + return await db.reports.add(report); +} + +/** + * Get all reports by market type + */ +export async function getReportsByMarketType( + market_type: "us" | "twse" | "tpex" +): Promise { + return await db.reports + .where('market_type') + .equals(market_type) + .reverse() + .sortBy('saved_at'); +} + +/** + * Get all saved reports, sorted by saved_at descending + */ +export async function getAllReports(): Promise { + return await db.reports + .orderBy('saved_at') + .reverse() + .toArray(); +} + +/** + * Get a single report by ID + */ +export async function getReportById(id: number): Promise { + return await db.reports.get(id); +} + +/** + * Delete a report by ID + */ +export async function deleteReport(id: number): Promise { + await db.reports.delete(id); +} + +/** + * Delete multiple reports by IDs + */ +export async function deleteReports(ids: number[]): Promise { + await db.reports.bulkDelete(ids); +} + +/** + * Get report count by market type + */ +export async function getReportCountByMarketType(): Promise<{ + us: number; + twse: number; + tpex: number; +}> { + const [us, twse, tpex] = await Promise.all([ + db.reports.where('market_type').equals('us').count(), + db.reports.where('market_type').equals('twse').count(), + db.reports.where('market_type').equals('tpex').count(), + ]); + + return { us, twse, tpex }; +} + +/** + * Check if a report with the same ticker and analysis_date already exists + */ +export async function checkDuplicateReport( + ticker: string, + analysis_date: string +): Promise { + return await db.reports + .where('ticker') + .equals(ticker) + .and(report => report.analysis_date === analysis_date) + .first(); +} + +// Export the db instance for advanced usage +export { db }; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 54d7fbd5..a7a0d05a 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -47,6 +47,7 @@ export interface AnalysisResponse { status: string; ticker: string; analysis_date: string; + market_type?: "us" | "twse" | "tpex"; // 市場類型:美股、上市、上櫃/興櫃 decision?: any; reports?: any; error?: string; diff --git a/frontend/package.json b/frontend/package.json index ea75fd1c..b132061a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dexie": "^4.2.1", "lucide-react": "^0.554.0", "next": "16.0.7", "next-themes": "^0.4.6",