diff --git a/src/api/routes/analyze-ticker.ts b/src/api/routes/analyze-ticker.ts index 25515b9..1d84745 100644 --- a/src/api/routes/analyze-ticker.ts +++ b/src/api/routes/analyze-ticker.ts @@ -248,6 +248,7 @@ export async function handleAnalyzeTicker(request: Request): Promise { analysis: analysisResult, }; + console.log("Ticker analysis response:", response); return jsonResponse(successResponse(response), 200, origin); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/api/routes/refresh.ts b/src/api/routes/refresh.ts index 240a9df..d273330 100644 --- a/src/api/routes/refresh.ts +++ b/src/api/routes/refresh.ts @@ -32,8 +32,16 @@ export async function handleRefresh(request: Request): Promise { return jsonResponse(successResponse(response), 200, origin); } - // Determine schedule based on time - const hour = new Date().getHours(); + // Determine schedule based on WIB time (UTC+7) + const options = { timeZone: "Asia/Jakarta", hour12: false }; + const parts = new Intl.DateTimeFormat('en-US', { + ...options, + hour: 'numeric', + }).formatToParts(new Date()); + + let hour = parseInt(parts.find(p => p.type === 'hour')?.value || "0"); + if (hour === 24) hour = 0; // Intl format sometimes returns 24 for 00:00 + const schedule: JobSchedule = hour < 12 ? "morning" : "evening"; const today = new Date().toISOString().slice(0, 10); diff --git a/src/lib/analyzer/llm-client.ts b/src/lib/analyzer/llm-client.ts index c1f160a..1f27813 100644 --- a/src/lib/analyzer/llm-client.ts +++ b/src/lib/analyzer/llm-client.ts @@ -101,6 +101,7 @@ export async function generateContent( // Fallback to regex parsing if strict JSON parsing fails parsedJson = parseJsonResponse(text); if (!parsedJson) { + console.error("RAW LLM OUTPUT:", text); throw new Error("Failed to parse JSON from response text"); } } diff --git a/src/lib/analyzer/schemas.ts b/src/lib/analyzer/schemas.ts index 244ce01..ffe5c14 100644 --- a/src/lib/analyzer/schemas.ts +++ b/src/lib/analyzer/schemas.ts @@ -5,48 +5,96 @@ import { z } from "zod"; */ export const tickerExtractionSchema = z.object({ tickers: z.array( - z.object({ - code: z.string().toUpperCase().min(1), - sentiment: z.number().min(-1).max(1), - relevance: z.number().min(0).max(1), - reason: z.string(), - }) + z.preprocess( + (val) => { + if (typeof val === "string") { + return { code: val, sentiment: 0, relevance: 0.5, reason: "Extracted from text" }; + } + if (val && typeof val === "object") { + const v = val as any; + return { + ...v, + code: typeof v.code === "string" ? v.code : String(v.code || v.ticker || v.name || "UNKNOWN"), + sentiment: typeof v.sentiment === "number" ? v.sentiment : 0, + relevance: typeof v.relevance === "number" ? v.relevance : 0.5, + reason: typeof v.reason === "string" ? v.reason : "Extracted from text", + }; + } + return val; + }, + z.object({ + code: z.string().toUpperCase().min(1), + sentiment: z.number().min(-1).max(1), + relevance: z.number().min(0).max(1), + reason: z.string(), + }) + ) ), }); /** * Stock Analysis Schema */ -export const stockAnalysisSchema = z.object({ - action: z.enum(["BUY", "HOLD", "AVOID"]), - confidence: z.number().min(1).max(10), - entryPrice: z.number().nonnegative(), - stopLoss: z.number().nonnegative(), - targetPrice: z.number().nonnegative(), - maxHoldDays: z.number().int().positive(), - orderType: z.enum(["LIMIT", "MARKET"]).default("LIMIT"), - scores: z.object({ - sentiment: z.number().min(0).max(100), - fundamental: z.number().min(0).max(100), - technical: z.number().min(0).max(100), - overall: z.number().min(0).max(100), - }), - reasoning: z.object({ - news: z.string(), - fundamental: z.string(), - technical: z.string(), - summary: z.string(), - }), - previousPredictionUpdates: z.array( - z.object({ - ticker: z.string().toUpperCase(), - action: z.enum(["HOLD", "EXIT", "TAKE_PROFIT", "ADD"]), - reason: z.string(), - newStopLoss: z.number().nonnegative().optional(), - newTarget: z.number().nonnegative().optional(), - }) - ).optional().default([]), -}); +export const stockAnalysisSchema = z.preprocess( + (val) => { + if (typeof val === "string") { + try { return JSON.parse(val); } catch { return val; } + } + return val; + }, + z.object({ + action: z.enum(["BUY", "HOLD", "AVOID"]), + confidence: z.number().min(1).max(10), + entryPrice: z.number().nonnegative(), + stopLoss: z.number().nonnegative(), + targetPrice: z.number().nonnegative(), + maxHoldDays: z.number().int().positive(), + orderType: z.enum(["LIMIT", "MARKET"]).default("LIMIT"), + scores: z.preprocess((val) => { + if (typeof val === "string") { + try { return JSON.parse(val); } catch { return { sentiment: 0, fundamental: 0, technical: 0, overall: 0 }; } + } + return val; + }, z.object({ + sentiment: z.number().min(0).max(100), + fundamental: z.number().min(0).max(100), + technical: z.number().min(0).max(100), + overall: z.number().min(0).max(100), + })), + reasoning: z.preprocess((val) => { + if (typeof val === "string") { + try { + return JSON.parse(val); + } catch { + return { summary: val, news: "", fundamental: "", technical: "" }; + } + } + return val; + }, z.object({ + news: z.string().optional().default(""), + fundamental: z.string().optional().default(""), + technical: z.string().optional().default(""), + summary: z.string().optional().default(""), + })), + previousPredictionUpdates: z.array( + z.preprocess( + (val) => { + if (typeof val === "string") { + try { return JSON.parse(val); } catch { return val; } + } + return val; + }, + z.object({ + ticker: z.string().toUpperCase(), + action: z.enum(["HOLD", "EXIT", "TAKE_PROFIT", "ADD"]), + reason: z.string(), + newStopLoss: z.number().nonnegative().optional(), + newTarget: z.number().nonnegative().optional(), + }) + ) + ).optional().default([]), + }) +); export type TickerExtractionResponse = z.infer; export type StockAnalysisResponse = z.infer; diff --git a/src/lib/prediction-tracker/status-checker.ts b/src/lib/prediction-tracker/status-checker.ts index eca926a..34d7f91 100644 --- a/src/lib/prediction-tracker/status-checker.ts +++ b/src/lib/prediction-tracker/status-checker.ts @@ -25,19 +25,21 @@ export function checkStatusChange( // Pending - check if entry price is hit if (status === "pending") { - // For LIMIT orders: do NOT auto-transition to entry_hit. - // Entry should only happen after market close (manual or scheduled). - // For MARKET orders: auto-transition when price is hit. - const orderType = prediction.orderType ?? "LIMIT"; - - if (orderType === "MARKET" && isPriceHit(currentPrice, entryPrice)) { + // For ANY order type (LIMIT or MARKET): auto-transition when price touches entry + // A MARKET order buys right at the open price, but we just check if it touches the entry. + // We treat both similarly for real-time tracking: if the price hits or drops below the entryPrice, we enter. + const tolerance = 0.005; // 0.5% tolerance + const upperBound = entryPrice * (1 + tolerance); + + // We consider the entry hit if current price is <= upperBound of entry price + if (currentPrice <= upperBound) { return { id, ticker, previousStatus: status, newStatus: "entry_hit", price: currentPrice, - reason: `MARKET order: Entry price ${entryPrice} reached at ${currentPrice}`, + reason: `Entry price ${entryPrice} reached at ${currentPrice}`, timestamp: new Date(), }; } diff --git a/src/services/monitor.ts b/src/services/monitor.ts index 8e2febf..287e763 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -114,9 +114,7 @@ async function runCheck() { }); } - for (const update of result.statusUpdates) { - await notifyStatusChange(update); - } + await notifyBatchedStatusChanges(result.statusUpdates); } // 3. Anomaly Detection @@ -171,20 +169,58 @@ async function runCheck() { } } -async function notifyStatusChange(update: StatusUpdate) { - const { ticker, previousStatus, newStatus, price, reason } = update; - const emoji = getStatusEmoji(newStatus); +async function notifyBatchedStatusChanges(updates: StatusUpdate[]) { + if (updates.length === 0) return; + + // Single update: keep the detailed format + if (updates.length === 1) { + const update = updates[0]; + if (!update) return; + const emoji = getStatusEmoji(update.newStatus); + const time = update.timestamp.toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" }); + + const message = ` +${emoji} *${update.ticker} Status Update* + +*Previous:* ${formatStatus(update.previousStatus)} +*New:* ${formatStatus(update.newStatus)} - const message = ` -${emoji} *${ticker} Update* +💰 *Price:* ${update.price} +⏱ *Time:* ${time} -Status: ${formatStatus(previousStatus)} ➡️ ${formatStatus(newStatus)} -Price: ${price} -Reason: ${reason} -Time: ${update.timestamp.toLocaleTimeString("id-ID")} - `.trim(); +📝 *Details:* ${update.reason} - await sendTelegramNotification(message); +_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. DYOR._ + `.trim(); + + await sendTelegramNotification(message); + return; + } + + // Multiple updates: Group by newStatus + const grouped = new Map(); + for (const u of updates) { + const arr = grouped.get(u.newStatus) || []; + arr.push(u); + grouped.set(u.newStatus, arr); + } + + let message = `📊 *Sentimeter Batched Updates*\n`; + message += `_(${updates.length} updates at ${new Date().toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" })})_\n\n`; + + for (const [status, groupUpdates] of grouped.entries()) { + const emoji = getStatusEmoji(status); + message += `${emoji} *${formatStatus(status)}*\n`; + for (const u of groupUpdates) { + // Create concise bullet points + message += `• ${u.ticker}: ${u.reason}\n`; + } + message += `\n`; + } + + message += `_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. DYOR._`; + + await sendTelegramNotification(message.trim()); } async function notifyAnomaly(anomaly: AnomalyDetected, analysis: string) { @@ -192,12 +228,14 @@ async function notifyAnomaly(anomaly: AnomalyDetected, analysis: string) { const message = ` ${emoji} *${anomaly.ticker} Anomaly Detected* -Type: ${anomaly.type} -Value: ${anomaly.type === "PRICE" ? anomaly.value.toFixed(2) + "%" : anomaly.value} -Message: ${anomaly.message} +*Type:* ${anomaly.type} +*Value:* ${anomaly.type === "PRICE" ? anomaly.value.toFixed(2) + "%" : anomaly.value} +*Details:* ${anomaly.message} 🤖 *AI Analysis:* ${analysis} + +_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. DYOR._ `.trim(); await sendTelegramNotification(message); diff --git a/src/services/telegram-poller.ts b/src/services/telegram-poller.ts index e4f004a..aabdb0a 100644 --- a/src/services/telegram-poller.ts +++ b/src/services/telegram-poller.ts @@ -112,7 +112,7 @@ async function handleUpdate(update: TelegramUpdate) { const user = message.from; if (text === "/start") { - console.log(`👤 New Telegram user: ${user.first_name} (${chatId})`); + console.log(`👤 New Telegram user: ${user.first_name} ${user.last_name} (${user.username})`); upsertTelegramUser({ chatId, @@ -122,16 +122,43 @@ async function handleUpdate(update: TelegramUpdate) { isActive: true, }); - await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, "✅ You are now subscribed to Sentimeter alerts!"); + const welcomeMsg = ` +🎉 *Welcome to Sentimeter!* + +Hello ${user.first_name}! You are now successfully subscribed to receive AI-driven stock alerts. 📈 + +I will notify you here whenever: +🟢 A new position is entered +🎯 A target price is hit +🛑 A stop loss is triggered +⏰ A trade expires +🚀 Unusual volume or price anomalies are detected + +_⚠️ Disclaimer: Prices may be delayed by up to 10 mins. Not financial advice. Always DYOR before trading._ + +🐙 *Open Source:* [GitHub Repository](https://github.com/snowfluke/sentimeter) + +Type /stop at any time if you wish to unsubscribe. + `.trim(); + + await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, welcomeMsg); } else if (text === "/stop") { - console.log(`👤 User unsubscribed: ${user.first_name} (${chatId})`); + console.log(`👤 User unsubscribed: ${user.first_name} ${user.last_name} (${user.username})`); upsertTelegramUser({ chatId, isActive: false, }); - await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, "🔕 You have unsubscribed from alerts."); + const goodbyeMsg = ` +🔕 *Alerts Disabled* + +You have successfully unsubscribed from Sentimeter alerts. + +If you ever want to come back and start receiving notifications again, just type /start! 👋 + `.trim(); + + await sendDirectMessage(process.env.TELEGRAM_BOT_TOKEN!, chatId, goodbyeMsg); } } diff --git a/web/src/components/ActivePositionCard.tsx b/web/src/components/ActivePositionCard.tsx index d9f721d..7f95eb1 100644 --- a/web/src/components/ActivePositionCard.tsx +++ b/web/src/components/ActivePositionCard.tsx @@ -21,7 +21,7 @@ export function ActivePositionCard({ position }: ActivePositionCardProps) { return ( -
+

{position.ticker}

@@ -31,7 +31,7 @@ export function ActivePositionCard({ position }: ActivePositionCardProps) {

{position.companyName}

-
+

{formatPercent(position.unrealizedPnlPct)}

diff --git a/web/src/components/LogPanel.tsx b/web/src/components/LogPanel.tsx index ad5183b..9655c66 100644 --- a/web/src/components/LogPanel.tsx +++ b/web/src/components/LogPanel.tsx @@ -10,7 +10,6 @@ import type { LogEntry } from "@/lib"; interface LogPanelProps { logs: LogEntry[]; connected: boolean; - onClear: () => void; visible?: boolean; } @@ -30,7 +29,7 @@ const levelIcons: Record = { step: "📍", }; -export function LogPanel({ logs, connected, onClear, visible = false }: LogPanelProps) { +export function LogPanel({ logs, connected, visible = false }: LogPanelProps) { const containerRef = useRef(null); useEffect(() => { @@ -45,7 +44,7 @@ export function LogPanel({ logs, connected, onClear, visible = false }: LogPanel return (
-
+
Analysis Logs
-
{ + setIsMobileMenuOpen(false); + }, [location]); + return (
-
- +
+ {new Date().toLocaleDateString("id-ID", { weekday: "long", day: "numeric", @@ -84,9 +90,114 @@ export function Navigation() { year: "numeric", })} + + + +
+ +
+ + {/* Mobile menu, show/hide based on menu state. */} + {isMobileMenuOpen && ( +
+
+ + `block pl-3 pr-4 py-2 border-l-4 text-base font-medium ${isActive + ? "bg-primary-50 border-primary-500 text-primary-700" + : "border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800" + }` + } + > + Dashboard + + + `block pl-3 pr-4 py-2 border-l-4 text-base font-medium ${isActive + ? "bg-primary-50 border-primary-500 text-primary-700" + : "border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800" + }` + } + > + History + + + `block pl-3 pr-4 py-2 border-l-4 text-base font-medium ${isActive + ? "bg-primary-50 border-primary-500 text-primary-700" + : "border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800" + }` + } + > + Analyze + + + `block pl-3 pr-4 py-2 border-l-4 text-base font-medium ${isActive + ? "bg-primary-50 border-primary-500 text-primary-700" + : "border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800" + }` + } + > + Config + +
+
+
+ + {new Date().toLocaleDateString("id-ID", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + })} + + + + +
+
+
+ )} ); } diff --git a/web/src/components/RecommendationCard.tsx b/web/src/components/RecommendationCard.tsx index 165f7c8..b61d268 100644 --- a/web/src/components/RecommendationCard.tsx +++ b/web/src/components/RecommendationCard.tsx @@ -15,7 +15,7 @@ interface RecommendationCardProps { export function RecommendationCard({ recommendation: rec }: RecommendationCardProps) { return ( -
+

{rec.ticker}

@@ -30,7 +30,7 @@ export function RecommendationCard({ recommendation: rec }: RecommendationCardPr
-
+

Entry

{formatCurrency(rec.entryPrice)}

diff --git a/web/src/components/SummaryTable.tsx b/web/src/components/SummaryTable.tsx index 043d776..4cd1c99 100644 --- a/web/src/components/SummaryTable.tsx +++ b/web/src/components/SummaryTable.tsx @@ -97,17 +97,35 @@ export function SummaryTable({ recommendations, activePositions, date }: Summary const [page, setPage] = useState(0); const rows: TableRow[] = [ - ...recommendations.map((rec): TableRow => ({ - ticker: rec.ticker, - signal: "BUY", - entry: rec.entryPrice, - current: rec.currentPrice, - target: rec.targetPrice, - stopLoss: rec.stopLoss, - pnl: null, - score: rec.overallScore, - days: 0, - })), + ...recommendations.map((rec): TableRow => { + const recDate = new Date(rec.recommendationDate); + const targetDate = new Date(date); + recDate.setHours(0, 0, 0, 0); + targetDate.setHours(0, 0, 0, 0); + const diffTime = targetDate.getTime() - recDate.getTime(); + const diffDays = Math.max(0, Math.floor(diffTime / (1000 * 60 * 60 * 24))); + const tolerance = 0.005; + const upperBound = rec.entryPrice * (1 + tolerance); + + const pnl = + rec.currentPrice !== null && + rec.currentPrice !== undefined && + rec.currentPrice <= upperBound + ? ((rec.currentPrice - rec.entryPrice) / rec.entryPrice) * 100 + : null; + + return { + ticker: rec.ticker, + signal: "BUY", + entry: rec.entryPrice, + current: rec.currentPrice, + target: rec.targetPrice, + stopLoss: rec.stopLoss, + pnl: pnl, + score: rec.overallScore, + days: diffDays, + }; + }), ...activePositions.map((pos): TableRow => ({ ticker: pos.ticker, signal: deriveSignal(pos), @@ -153,34 +171,34 @@ export function SummaryTable({ recommendations, activePositions, date }: Summary
- +
- - - - - - - - + + + + + + + + {pagedRows.map((row, idx) => ( - - + - - - - - - + + + + + + ))} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3235f49..54dac9e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -53,8 +53,9 @@ export interface ActivePositionItem { export interface PredictionSummary { totalActive: number; - totalPending: number; - totalClosed: number; + pending: number; + entryHit: number; + closedToday: number; winRate: number | null; avgReturn: number | null; } diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index e305e66..9351577 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -171,7 +171,7 @@ function ConfigContent() { return (
-
+

Config

diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index b81d851..2e82ae7 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -21,23 +21,27 @@ import { useLogStream, useWebSocket, useAvoidList, useMarketOutlook, formatPercent, + useHistory, type ActivePositionItem, + type RecommendationItem, } from "@/lib"; import { useState, useEffect, useCallback } from "react"; export function DashboardPage() { const { data, loading, error, refetch } = useRecommendations(); const { trigger, loading: refreshing, result: refreshResult } = useRefresh(); - const { logs, connected, clear: clearLogs } = useLogStream(); + const { logs, connected } = useLogStream(); const { showToast } = useToast(); const [activePositions, setActivePositions] = useState([]); + const [recommendations, setRecommendations] = useState([]); const [currentTime, setCurrentTime] = useState(new Date()); - // Initialize active positions from API data + // Initialize positions from API data useEffect(() => { - if (data?.activePositions) { - setActivePositions(data.activePositions); + if (data) { + if (data.activePositions) setActivePositions(data.activePositions); + if (data.recommendations) setRecommendations(data.recommendations); } }, [data]); @@ -58,6 +62,18 @@ export function DashboardPage() { return pos; }) ); + setRecommendations((prev) => + prev.map((rec) => { + const newPrice = message.prices[rec.ticker]; + if (newPrice) { + return { + ...rec, + currentPrice: newPrice, + }; + } + return rec; + }) + ); } else if (message.type === "STATUS_UPDATE") { message.updates.forEach((update: any) => { const type = @@ -102,29 +118,33 @@ export function DashboardPage() { const { isConnected } = useWebSocket("/ws", handleWebSocketMessage); const { data: avoidData } = useAvoidList(); const { data: outlookData } = useMarketOutlook(); + const { data: historyData } = useHistory({ page: 1, pageSize: 1 }); // Fetch summary stats if (loading) return ; if (error) return ; if (!data) return ; + const winRate = historyData?.stats?.winRate ?? data.summary.winRate; + const avgReturn = historyData?.stats?.avgReturn ?? data.summary.avgReturn; + const stats = [ { label: "Active Positions", value: activePositions.length }, - { label: "Pending Entry", value: data.summary.totalPending }, + { label: "Pending Entry", value: data.summary.pending }, { label: "Win Rate", - value: data.summary.winRate ? `${data.summary.winRate.toFixed(1)}%` : "-", + value: winRate !== null ? `${winRate.toFixed(1)}%` : "-", }, { label: "Avg Return", - value: data.summary.avgReturn - ? formatPercent(data.summary.avgReturn) + value: avgReturn !== null + ? formatPercent(avgReturn) : "-", }, ]; return (

-
+

Today's Recommendations @@ -133,7 +153,7 @@ export function DashboardPage() { {data.schedule === "morning" ? "Morning" : "Evening"} session -{" "} {currentTime.toLocaleTimeString("id-ID")} {isConnected && ( - + ● Live )} @@ -177,8 +197,7 @@ export function DashboardPage() { {outlookData && } @@ -186,7 +205,7 @@ export function DashboardPage() { @@ -211,13 +230,13 @@ export function DashboardPage() {

- New Recommendations ({data.recommendations.length}) + New Recommendations ({recommendations.length})

- {data.recommendations.length === 0 ? ( + {recommendations.length === 0 ? ( ) : (
- {data.recommendations.map((rec) => ( + {recommendations.map((rec) => ( 0 && ( )} + +
+

+ Disclaimer: Market Data & Not Financial Advice +

+

+ Please note that "real-time" market prices provided via Yahoo Finance may be delayed by up to 10 minutes. + The AI recommendations and analyses presented by Sentimeter are for informational and educational purposes only, and are not guaranteed to be perfectly accurate. + Always Do Your Own Research (DYOR) before making any investment decisions. + We are not responsible for any financial losses or damages resulting from the use of this system. +

+
); } diff --git a/web/src/pages/HistoryPage.tsx b/web/src/pages/HistoryPage.tsx index 009e813..da1b1a9 100644 --- a/web/src/pages/HistoryPage.tsx +++ b/web/src/pages/HistoryPage.tsx @@ -90,8 +90,8 @@ export function HistoryPage() { const stats = [ { label: "Total Recommendations", value: data.stats.totalRecommendations }, - { label: "Win Rate", value: data.stats.winRate ? `${data.stats.winRate.toFixed(1)}%` : "-" }, - { label: "Avg Return", value: data.stats.avgReturn ? formatPercent(data.stats.avgReturn) : "-" }, + { label: "Win Rate", value: data.stats.winRate !== null ? `${data.stats.winRate.toFixed(1)}%` : "-" }, + { label: "Avg Return", value: data.stats.avgReturn !== null ? formatPercent(data.stats.avgReturn) : "-" }, { label: "Best Pick", value: data.stats.bestPick ? `${data.stats.bestPick.ticker} (${formatPercent(data.stats.bestPick.returnPct)})` : "-", @@ -108,17 +108,17 @@ export function HistoryPage() { -
+
handleTickerChange(e.target.value)} aria-label="Filter by ticker" /> handleFilterChange("startDate", e.target.value)} aria-label="Start date" /> handleFilterChange("endDate", e.target.value)} aria-label="End date" @@ -147,7 +147,7 @@ export function HistoryPage() { @@ -159,79 +159,78 @@ export function HistoryPage() { ) : ( <>
-

TickerSignalEntryCurrentTargetSLP&LDaysTickerSignalEntryCurrentTargetSLP&LDays
{row.ticker} + {row.ticker} {SIGNAL_LABELS[row.signal]} {formatPrice(row.entry)}{formatPrice(row.current)}{formatPrice(row.target)}{formatPrice(row.stopLoss)}{formatPnl(row.pnl)}{row.days}{formatPrice(row.entry)}{formatPrice(row.current)}{formatPrice(row.target)}{formatPrice(row.stopLoss)}{formatPnl(row.pnl)}{row.days}
+
- - - - - - - - - + + + + + + + + + {data.items.map((item, index) => ( - - - + - - + - - - + ))}
TickerDateActionEntryTargetStop LossStatusP&LScoreTickerDateActionEntryTargetStop LossStatusP&LScore
+

{item.ticker}

{item.companyName}

{formatDate(item.recommendationDate)} + {formatDate(item.recommendationDate)} {item.action.toUpperCase()} {formatCurrency(item.entryPrice)} + {formatCurrency(item.entryPrice)} {formatCurrency(item.targetPrice)} + {formatCurrency(item.stopLoss)} + {getStatusLabel(item.status)} = 0 - ? "text-success-600" - : "text-danger-600" - }`} + className={`py-3 px-4 text-right text-sm font-medium ${item.profitLossPct === null + ? "text-gray-400" + : item.profitLossPct >= 0 + ? "text-success-600" + : "text-danger-600" + }`} > {formatPercent(item.profitLossPct)} {item.overallScore.toFixed(1)}{item.overallScore.toFixed(1)}
-
-

+

+

Showing {(data.pagination.page - 1) * data.pagination.pageSize + 1} to{" "} {Math.min(data.pagination.page * data.pagination.pageSize, data.pagination.total)} of{" "} {data.pagination.total} results

-
+
-
+

= 0 ? "text-success-600" : "text-danger-600" - }`} + className={`text-sm font-medium ${data.priceChangePct >= 0 ? "text-success-600" : "text-danger-600" + }`} > {formatPercent(data.priceChangePct)}

@@ -164,13 +163,12 @@ export function TickerAnalysisPage() {

AI Analysis

{data.analysis.action}