new
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
|
@ -8,6 +8,118 @@ from typing import Optional
|
|||
|
||||
DB_PATH = Path(__file__).parent / "recommendations.db"
|
||||
|
||||
NIFTY_50_NAMES = {
|
||||
'RELIANCE': 'Reliance Industries Ltd',
|
||||
'TCS': 'Tata Consultancy Services Ltd',
|
||||
'HDFCBANK': 'HDFC Bank Ltd',
|
||||
'INFY': 'Infosys Ltd',
|
||||
'ICICIBANK': 'ICICI Bank Ltd',
|
||||
'HINDUNILVR': 'Hindustan Unilever Ltd',
|
||||
'ITC': 'ITC Ltd',
|
||||
'SBIN': 'State Bank of India',
|
||||
'BHARTIARTL': 'Bharti Airtel Ltd',
|
||||
'KOTAKBANK': 'Kotak Mahindra Bank Ltd',
|
||||
'LT': 'Larsen & Toubro Ltd',
|
||||
'AXISBANK': 'Axis Bank Ltd',
|
||||
'ASIANPAINT': 'Asian Paints Ltd',
|
||||
'MARUTI': 'Maruti Suzuki India Ltd',
|
||||
'HCLTECH': 'HCL Technologies Ltd',
|
||||
'SUNPHARMA': 'Sun Pharmaceutical Industries Ltd',
|
||||
'TITAN': 'Titan Company Ltd',
|
||||
'BAJFINANCE': 'Bajaj Finance Ltd',
|
||||
'WIPRO': 'Wipro Ltd',
|
||||
'ULTRACEMCO': 'UltraTech Cement Ltd',
|
||||
'NESTLEIND': 'Nestle India Ltd',
|
||||
'NTPC': 'NTPC Ltd',
|
||||
'POWERGRID': 'Power Grid Corporation of India Ltd',
|
||||
'M&M': 'Mahindra & Mahindra Ltd',
|
||||
'TATAMOTORS': 'Tata Motors Ltd',
|
||||
'ONGC': 'Oil & Natural Gas Corporation Ltd',
|
||||
'JSWSTEEL': 'JSW Steel Ltd',
|
||||
'TATASTEEL': 'Tata Steel Ltd',
|
||||
'ADANIENT': 'Adani Enterprises Ltd',
|
||||
'ADANIPORTS': 'Adani Ports and SEZ Ltd',
|
||||
'COALINDIA': 'Coal India Ltd',
|
||||
'BAJAJFINSV': 'Bajaj Finserv Ltd',
|
||||
'TECHM': 'Tech Mahindra Ltd',
|
||||
'HDFCLIFE': 'HDFC Life Insurance Company Ltd',
|
||||
'SBILIFE': 'SBI Life Insurance Company Ltd',
|
||||
'GRASIM': 'Grasim Industries Ltd',
|
||||
'DIVISLAB': "Divi's Laboratories Ltd",
|
||||
'DRREDDY': "Dr. Reddy's Laboratories Ltd",
|
||||
'CIPLA': 'Cipla Ltd',
|
||||
'BRITANNIA': 'Britannia Industries Ltd',
|
||||
'EICHERMOT': 'Eicher Motors Ltd',
|
||||
'APOLLOHOSP': 'Apollo Hospitals Enterprise Ltd',
|
||||
'INDUSINDBK': 'IndusInd Bank Ltd',
|
||||
'HEROMOTOCO': 'Hero MotoCorp Ltd',
|
||||
'TATACONSUM': 'Tata Consumer Products Ltd',
|
||||
'BPCL': 'Bharat Petroleum Corporation Ltd',
|
||||
'UPL': 'UPL Ltd',
|
||||
'HINDALCO': 'Hindalco Industries Ltd',
|
||||
'BAJAJ-AUTO': 'Bajaj Auto Ltd',
|
||||
'LTIM': 'LTIMindtree Ltd',
|
||||
}
|
||||
|
||||
|
||||
def _truncate_at_word(text: str, max_len: int) -> str:
|
||||
"""Truncate text at a word boundary to avoid mid-word cuts."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
truncated = text[:max_len]
|
||||
last_space = truncated.rfind(' ')
|
||||
if last_space > max_len * 0.6:
|
||||
return truncated[:last_space] + '...'
|
||||
return truncated + '...'
|
||||
|
||||
|
||||
def _extract_reason(raw_analysis: str) -> str:
|
||||
"""Extract clean reason text from raw LLM analysis output.
|
||||
|
||||
Strips markdown bold markers and skips the metadata prefix
|
||||
(FINAL DECISION, HOLD_DAYS, CONFIDENCE, RISK_LEVEL) to return
|
||||
the actual analytical content, truncated at word boundaries.
|
||||
"""
|
||||
if not raw_analysis:
|
||||
return ''
|
||||
|
||||
text = raw_analysis.replace('**', '').strip()
|
||||
|
||||
# Look for "RISK ASSESSMENT:" or "RISK_ASSESSMENT:" and return text after it
|
||||
for marker in ('RISK ASSESSMENT:', 'RISK_ASSESSMENT:'):
|
||||
idx = text.upper().find(marker.upper())
|
||||
if idx != -1:
|
||||
after = text[idx + len(marker):].strip()
|
||||
if after:
|
||||
return _truncate_at_word(after, 500)
|
||||
|
||||
# Look for the last substantive paragraph after metadata lines
|
||||
lines = text.split('\n')
|
||||
content_lines = []
|
||||
past_metadata = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
upper_line = stripped.upper()
|
||||
is_metadata = (
|
||||
upper_line.startswith('FINAL DECISION:')
|
||||
or upper_line.startswith('HOLD_DAYS:')
|
||||
or upper_line.startswith('CONFIDENCE:')
|
||||
or upper_line.startswith('RISK_LEVEL:')
|
||||
)
|
||||
if is_metadata:
|
||||
past_metadata = True
|
||||
continue
|
||||
if past_metadata or not is_metadata:
|
||||
content_lines.append(stripped)
|
||||
|
||||
if content_lines:
|
||||
return _truncate_at_word(' '.join(content_lines), 500)
|
||||
|
||||
# Fallback: return original text truncated
|
||||
return _truncate_at_word(text, 500)
|
||||
|
||||
|
||||
def sanitize_decision(raw: str) -> str:
|
||||
"""Extract BUY/SELL/HOLD from potentially noisy LLM output.
|
||||
|
|
@ -1470,18 +1582,18 @@ def update_daily_recommendation_summary(date: str):
|
|||
buy_count += 1
|
||||
buy_stocks.append({
|
||||
'symbol': row['symbol'],
|
||||
'company_name': row['company_name'] or row['symbol'],
|
||||
'company_name': NIFTY_50_NAMES.get(row['symbol'], row['company_name'] or row['symbol']),
|
||||
'confidence': row['confidence'] or 'MEDIUM',
|
||||
'reason': (row['raw_analysis'] or '')[:200],
|
||||
'reason': _extract_reason(row['raw_analysis'] or ''),
|
||||
'rank': row['rank']
|
||||
})
|
||||
elif decision == 'SELL':
|
||||
sell_count += 1
|
||||
sell_stocks.append({
|
||||
'symbol': row['symbol'],
|
||||
'company_name': row['company_name'] or row['symbol'],
|
||||
'company_name': NIFTY_50_NAMES.get(row['symbol'], row['company_name'] or row['symbol']),
|
||||
'confidence': row['confidence'] or 'MEDIUM',
|
||||
'reason': (row['raw_analysis'] or '')[:200],
|
||||
'reason': _extract_reason(row['raw_analysis'] or ''),
|
||||
'rank': row['rank']
|
||||
})
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { TrendingUp, TrendingDown, Minus, ChevronRight, Clock } from 'lucide-react';
|
||||
import type { StockAnalysis, Decision } from '../types';
|
||||
import { NIFTY_50_STOCKS } from '../types';
|
||||
|
||||
/** Resolve the display name for a stock, falling back to NIFTY_50_STOCKS when the backend stored the ticker as company_name */
|
||||
function getCompanyName(symbol: string, providedName?: string): string {
|
||||
if (providedName && providedName !== symbol) return providedName;
|
||||
return NIFTY_50_STOCKS.find(s => s.symbol === symbol)?.company_name || symbol;
|
||||
}
|
||||
|
||||
interface StockCardProps {
|
||||
stock: StockAnalysis;
|
||||
|
|
@ -151,7 +158,7 @@ export default function StockCard({ stock, showDetails = true, compact = false }
|
|||
}`} aria-hidden="true" />
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 text-xs hidden sm:inline" aria-hidden="true">·</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate hidden sm:inline">{stock.company_name}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 truncate hidden sm:inline">{getCompanyName(stock.symbol, stock.company_name)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DecisionBadge decision={stock.decision} />
|
||||
|
|
@ -172,7 +179,7 @@ export default function StockCard({ stock, showDetails = true, compact = false }
|
|||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm">{stock.symbol}</h3>
|
||||
<DecisionBadge decision={stock.decision} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{stock.company_name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{getCompanyName(stock.symbol, stock.company_name)}</p>
|
||||
{showDetails && (
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<ConfidenceBadge confidence={stock.confidence} />
|
||||
|
|
@ -201,7 +208,7 @@ export function StockCardCompact({ stock }: { stock: StockAnalysis }) {
|
|||
<div>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{stock.symbol}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 mx-2">·</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{stock.company_name}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{getCompanyName(stock.symbol, stock.company_name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DecisionBadge decision={stock.decision} />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,21 @@ import { Trophy, AlertTriangle, TrendingUp, TrendingDown, ChevronRight } from 'l
|
|||
import type { TopPick, StockToAvoid } from '../types';
|
||||
import { RankBadge } from './StockCard';
|
||||
|
||||
/** Extract just the risk assessment reasoning from raw LLM output */
|
||||
function cleanReason(reason?: string): string {
|
||||
if (!reason) return '';
|
||||
let text = reason.replace(/\*\*/g, '').replace(/\*/g, '');
|
||||
// Try to extract text after "RISK ASSESSMENT:" or "RISK_ASSESSMENT:"
|
||||
const riskMatch = text.match(/RISK[_ ]ASSESSMENT:\s*([\s\S]*)/i);
|
||||
if (riskMatch) return riskMatch[1].trim();
|
||||
// Fallback: strip known metadata prefixes
|
||||
text = text.replace(/FINAL DECISION:\s*\w+\s*/i, '');
|
||||
text = text.replace(/HOLD_DAYS:\s*\S+\s*/i, '');
|
||||
text = text.replace(/CONFIDENCE:\s*\w+\s*/i, '');
|
||||
text = text.replace(/RISK_LEVEL:\s*\w+\s*/i, '');
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
interface TopPicksProps {
|
||||
picks: TopPick[];
|
||||
}
|
||||
|
|
@ -44,7 +59,7 @@ export default function TopPicks({ picks }: TopPicksProps) {
|
|||
BUY
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-2 leading-relaxed">{pick.reason?.replace(/\*\*/g, '').replace(/\*/g, '')}</p>
|
||||
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-2 leading-relaxed">{cleanReason(pick.reason)}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-md font-medium border ${
|
||||
pick.risk_level === 'LOW' ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border-emerald-200/50 dark:border-emerald-800/30' :
|
||||
|
|
@ -81,7 +96,7 @@ export function StocksToAvoid({ stocks }: StocksToAvoidProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{stocks.map((stock) => {
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -91,14 +106,17 @@ export function StocksToAvoid({ stocks }: StocksToAvoidProps) {
|
|||
style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.04), rgba(220,38,38,0.01))' }}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold text-white" style={{ background: 'linear-gradient(135deg, #ef4444, #dc2626)' }}>
|
||||
<TrendingDown className="w-3 h-3" />
|
||||
SELL
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-2 leading-relaxed">{stock.reason?.replace(/\*\*/g, '').replace(/\*/g, '')}</p>
|
||||
{stock.company_name && stock.company_name !== stock.symbol && (
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mb-1.5 truncate">{stock.company_name}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-600 dark:text-gray-400 line-clamp-2 mb-2 leading-relaxed">{cleanReason(stock.reason)}</p>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 group-hover:text-red-600 dark:group-hover:text-red-400 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ export default function Dashboard() {
|
|||
// Normal mode: only show analyzed stocks
|
||||
return (recommendation ? Object.values(recommendation.analysis) : []).map(s => ({
|
||||
symbol: s.symbol,
|
||||
company_name: s.company_name,
|
||||
company_name: NIFTY_50_STOCKS.find(n => n.symbol === s.symbol)?.company_name || s.company_name,
|
||||
liveState: 'completed' as StockLiveState,
|
||||
analysis: s,
|
||||
}));
|
||||
|
|
@ -538,8 +538,8 @@ export default function Dashboard() {
|
|||
{/* How It Works Section */}
|
||||
<HowItWorks collapsed={true} />
|
||||
|
||||
{/* Top Picks and Avoid Section - Side by Side Compact */}
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
{/* Top Picks and Avoid Section - Side by Side when both present */}
|
||||
<div className={`grid gap-4 ${recommendation.top_picks.length > 0 ? 'lg:grid-cols-2' : ''}`}>
|
||||
{recommendation.top_picks.length > 0 && (
|
||||
<TopPicks picks={recommendation.top_picks} />
|
||||
)}
|
||||
|
|
@ -561,10 +561,10 @@ export default function Dashboard() {
|
|||
<button
|
||||
onClick={() => setFilter('ALL')}
|
||||
aria-pressed={filter === 'ALL'}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
filter === 'ALL'
|
||||
? 'bg-nifty-600 text-white shadow-sm'
|
||||
: 'bg-gray-100 dark:bg-slate-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-500'
|
||||
: 'bg-gray-100 dark:bg-slate-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-500 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
All ({total})
|
||||
|
|
@ -572,10 +572,10 @@ export default function Dashboard() {
|
|||
<button
|
||||
onClick={() => setFilter('BUY')}
|
||||
aria-pressed={filter === 'BUY'}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
filter === 'BUY'
|
||||
? 'bg-green-600 text-white shadow-sm'
|
||||
: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/50'
|
||||
: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/50 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
Buy ({buy})
|
||||
|
|
@ -583,10 +583,10 @@ export default function Dashboard() {
|
|||
<button
|
||||
onClick={() => setFilter('HOLD')}
|
||||
aria-pressed={filter === 'HOLD'}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
filter === 'HOLD'
|
||||
? 'bg-amber-600 text-white shadow-sm'
|
||||
: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/50'
|
||||
: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/50 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
Hold ({hold})
|
||||
|
|
@ -594,10 +594,10 @@ export default function Dashboard() {
|
|||
<button
|
||||
onClick={() => setFilter('SELL')}
|
||||
aria-pressed={filter === 'SELL'}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
filter === 'SELL'
|
||||
? 'bg-red-600 text-white shadow-sm'
|
||||
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50'
|
||||
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
Sell ({sell})
|
||||
|
|
@ -605,7 +605,7 @@ export default function Dashboard() {
|
|||
<span className="mx-0.5 text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
onClick={() => setSortBy(sortBy === 'rank' ? 'symbol' : 'rank')}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 active:scale-95 ${
|
||||
sortBy === 'rank'
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-gray-100 dark:bg-slate-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-500'
|
||||
|
|
@ -623,7 +623,7 @@ export default function Dashboard() {
|
|||
placeholder="Search by symbol or company name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-2 text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:border-transparent"
|
||||
className="w-full pl-9 pr-9 py-2 text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:border-nifty-400 dark:focus:border-nifty-500 transition-all duration-200"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
|
|
@ -637,7 +637,7 @@ export default function Dashboard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2 max-h-[400px] overflow-y-auto" role="list" aria-label="Stock recommendations list">
|
||||
<div className="p-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 max-h-[400px] overflow-y-auto" role="list" aria-label="Stock recommendations list">
|
||||
{filteredItems.map((item) => {
|
||||
// COMPLETED with analysis data: clickable link
|
||||
if (item.liveState === 'completed' && item.analysis) {
|
||||
|
|
@ -645,14 +645,14 @@ export default function Dashboard() {
|
|||
<Link
|
||||
key={item.symbol}
|
||||
to={`/stock/${item.symbol}`}
|
||||
className="card-hover p-2 group relative overflow-hidden"
|
||||
className="card-hover p-2 group relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:border-nifty-300 dark:hover:border-nifty-600 hover:scale-[1.02]"
|
||||
role="listitem"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5 mb-0.5 min-w-0">
|
||||
<RankBadge rank={item.analysis.rank} size="small" />
|
||||
<span className="font-semibold text-sm text-gray-900 dark:text-gray-100">{item.symbol}</span>
|
||||
<DecisionBadge decision={item.analysis.decision} size="small" />
|
||||
<span className="flex-shrink-0"><DecisionBadge decision={item.analysis.decision} size="small" /></span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{item.company_name}</p>
|
||||
{item.analysis.hold_days != null && item.analysis.hold_days > 0 && item.analysis.decision !== 'SELL' && (
|
||||
|
|
@ -755,7 +755,7 @@ export default function Dashboard() {
|
|||
{/* Compact CTA */}
|
||||
<Link
|
||||
to="/history"
|
||||
className="flex items-center justify-between p-4 rounded-xl text-white group focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-2 transition-all hover:shadow-lg"
|
||||
className="flex items-center justify-between p-4 rounded-xl text-white group focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-2 transition-all duration-200 hover:shadow-lg hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #0284c7, #0369a1)',
|
||||
boxShadow: '0 2px 8px rgba(2, 132, 199, 0.25)',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Calendar, TrendingUp, TrendingDown, Minus, ChevronRight, ChevronDown, BarChart3, Target, HelpCircle, Activity, Calculator, LineChart, PieChart, Shield, Filter, Clock, Zap, Award, ArrowUpRight, ArrowDownRight, Play, Loader2, FileText, MessageSquare, Search, XCircle, AlertTriangle } from 'lucide-react';
|
||||
import type { ReturnBreakdown } from '../types';
|
||||
import { NIFTY_50_STOCKS } from '../types';
|
||||
import { DecisionBadge, HoldDaysBadge, RankBadge } from '../components/StockCard';
|
||||
import Sparkline from '../components/Sparkline';
|
||||
import AccuracyExplainModal from '../components/AccuracyExplainModal';
|
||||
|
|
@ -1390,7 +1391,7 @@ export default function History() {
|
|||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<RankBadge rank={stock.rank} size="small" />
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline truncate">{stock.company_name}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline truncate">{(stock.company_name && stock.company_name !== stock.symbol) ? stock.company_name : (NIFTY_50_STOCKS.find(n => n.symbol === stock.symbol)?.company_name || stock.company_name)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DecisionBadge decision={stock.decision} size="small" />
|
||||
|
|
|
|||
|
|
@ -20,6 +20,21 @@ import { api } from '../services/api';
|
|||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import type { FullPipelineData, PipelineStep, PipelineStepStatus } from '../types/pipeline';
|
||||
|
||||
/** Extract just the risk assessment reasoning from raw LLM output */
|
||||
function cleanReason(reason?: string): string {
|
||||
if (!reason) return '';
|
||||
let text = reason.replace(/\*\*/g, '').replace(/\*/g, '');
|
||||
// Try to extract text after "RISK ASSESSMENT:" or "RISK_ASSESSMENT:"
|
||||
const riskMatch = text.match(/RISK[_ ]ASSESSMENT:\s*([\s\S]*)/i);
|
||||
if (riskMatch) return riskMatch[1].trim();
|
||||
// Fallback: strip known metadata prefixes
|
||||
text = text.replace(/FINAL DECISION:\s*\w+\s*/i, '');
|
||||
text = text.replace(/HOLD_DAYS:\s*\S+\s*/i, '');
|
||||
text = text.replace(/CONFIDENCE:\s*\w+\s*/i, '');
|
||||
text = text.replace(/RISK_LEVEL:\s*\w+\s*/i, '');
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// Type for real backtest data from API
|
||||
interface BacktestResult {
|
||||
date: string;
|
||||
|
|
@ -940,17 +955,31 @@ export default function StockDetail() {
|
|||
</div>
|
||||
) : backtestResults.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100 dark:divide-slate-700 max-h-[320px] overflow-y-auto">
|
||||
{backtestResults.map((entry, idx) => (
|
||||
<div key={idx} className="px-3 py-2.5 flex items-center gap-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
{backtestResults.map((entry, idx) => {
|
||||
const isWeekend = [0, 6].includes(new Date(entry.date).getDay());
|
||||
return (
|
||||
<div key={idx} className={`px-3 py-2.5 flex items-center gap-3 transition-colors ${
|
||||
isWeekend
|
||||
? 'bg-gray-50/50 dark:bg-slate-800/30 opacity-60 hover:opacity-80'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
}`}>
|
||||
{/* Date */}
|
||||
<div className="w-16 flex-shrink-0">
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
<div className={`text-xs font-medium ${
|
||||
[0, 6].includes(new Date(entry.date).getDay())
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{new Date(entry.date).toLocaleDateString('en-IN', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
<div className={`text-[10px] ${
|
||||
[0, 6].includes(new Date(entry.date).getDay())
|
||||
? 'text-amber-500 dark:text-amber-400'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{new Date(entry.date).toLocaleDateString('en-IN', { weekday: 'short' })}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1005,7 +1034,8 @@ export default function StockDetail() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : history.length > 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
|
|
@ -1077,10 +1107,10 @@ export default function StockDetail() {
|
|||
<section className="card p-3 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-green-800 dark:text-green-300 text-sm">Top Pick: </span>
|
||||
<span className="text-sm text-green-700 dark:text-green-400">
|
||||
{latestRecommendation.top_picks.find(p => p.symbol === symbol)?.reason?.replace(/\*\*/g, '').replace(/\*/g, '')}
|
||||
<span className="text-sm text-green-700 dark:text-green-400 line-clamp-3">
|
||||
{cleanReason(latestRecommendation.top_picks.find(p => p.symbol === symbol)?.reason)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1091,10 +1121,10 @@ export default function StockDetail() {
|
|||
<section className="card p-3 bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-red-800 dark:text-red-300 text-sm">Avoid: </span>
|
||||
<span className="text-sm text-red-700 dark:text-red-400">
|
||||
{latestRecommendation.stocks_to_avoid.find(s => s.symbol === symbol)?.reason?.replace(/\*\*/g, '').replace(/\*/g, '')}
|
||||
<span className="text-sm text-red-700 dark:text-red-400 line-clamp-3">
|
||||
{cleanReason(latestRecommendation.stocks_to_avoid.find(s => s.symbol === symbol)?.reason)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 240 KiB |