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"
|
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:
|
def sanitize_decision(raw: str) -> str:
|
||||||
"""Extract BUY/SELL/HOLD from potentially noisy LLM output.
|
"""Extract BUY/SELL/HOLD from potentially noisy LLM output.
|
||||||
|
|
@ -1470,18 +1582,18 @@ def update_daily_recommendation_summary(date: str):
|
||||||
buy_count += 1
|
buy_count += 1
|
||||||
buy_stocks.append({
|
buy_stocks.append({
|
||||||
'symbol': row['symbol'],
|
'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',
|
'confidence': row['confidence'] or 'MEDIUM',
|
||||||
'reason': (row['raw_analysis'] or '')[:200],
|
'reason': _extract_reason(row['raw_analysis'] or ''),
|
||||||
'rank': row['rank']
|
'rank': row['rank']
|
||||||
})
|
})
|
||||||
elif decision == 'SELL':
|
elif decision == 'SELL':
|
||||||
sell_count += 1
|
sell_count += 1
|
||||||
sell_stocks.append({
|
sell_stocks.append({
|
||||||
'symbol': row['symbol'],
|
'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',
|
'confidence': row['confidence'] or 'MEDIUM',
|
||||||
'reason': (row['raw_analysis'] or '')[:200],
|
'reason': _extract_reason(row['raw_analysis'] or ''),
|
||||||
'rank': row['rank']
|
'rank': row['rank']
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { TrendingUp, TrendingDown, Minus, ChevronRight, Clock } from 'lucide-react';
|
import { TrendingUp, TrendingDown, Minus, ChevronRight, Clock } from 'lucide-react';
|
||||||
import type { StockAnalysis, Decision } from '../types';
|
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 {
|
interface StockCardProps {
|
||||||
stock: StockAnalysis;
|
stock: StockAnalysis;
|
||||||
|
|
@ -151,7 +158,7 @@ export default function StockCard({ stock, showDetails = true, compact = false }
|
||||||
}`} aria-hidden="true" />
|
}`} aria-hidden="true" />
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
|
<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-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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DecisionBadge decision={stock.decision} />
|
<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>
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate text-sm">{stock.symbol}</h3>
|
||||||
<DecisionBadge decision={stock.decision} />
|
<DecisionBadge decision={stock.decision} />
|
||||||
</div>
|
</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 && (
|
{showDetails && (
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<ConfidenceBadge confidence={stock.confidence} />
|
<ConfidenceBadge confidence={stock.confidence} />
|
||||||
|
|
@ -201,7 +208,7 @@ export function StockCardCompact({ stock }: { stock: StockAnalysis }) {
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{stock.symbol}</span>
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
<DecisionBadge decision={stock.decision} />
|
<DecisionBadge decision={stock.decision} />
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,21 @@ import { Trophy, AlertTriangle, TrendingUp, TrendingDown, ChevronRight } from 'l
|
||||||
import type { TopPick, StockToAvoid } from '../types';
|
import type { TopPick, StockToAvoid } from '../types';
|
||||||
import { RankBadge } from './StockCard';
|
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 {
|
interface TopPicksProps {
|
||||||
picks: TopPick[];
|
picks: TopPick[];
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +59,7 @@ export default function TopPicks({ picks }: TopPicksProps) {
|
||||||
BUY
|
BUY
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-md font-medium border ${
|
<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' :
|
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>
|
</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) => {
|
{stocks.map((stock) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<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))' }}
|
style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.04), rgba(220,38,38,0.01))' }}
|
||||||
>
|
>
|
||||||
<div className="relative z-10">
|
<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="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)' }}>
|
<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" />
|
<TrendingDown className="w-3 h-3" />
|
||||||
SELL
|
SELL
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ export default function Dashboard() {
|
||||||
// Normal mode: only show analyzed stocks
|
// Normal mode: only show analyzed stocks
|
||||||
return (recommendation ? Object.values(recommendation.analysis) : []).map(s => ({
|
return (recommendation ? Object.values(recommendation.analysis) : []).map(s => ({
|
||||||
symbol: s.symbol,
|
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,
|
liveState: 'completed' as StockLiveState,
|
||||||
analysis: s,
|
analysis: s,
|
||||||
}));
|
}));
|
||||||
|
|
@ -538,8 +538,8 @@ export default function Dashboard() {
|
||||||
{/* How It Works Section */}
|
{/* How It Works Section */}
|
||||||
<HowItWorks collapsed={true} />
|
<HowItWorks collapsed={true} />
|
||||||
|
|
||||||
{/* Top Picks and Avoid Section - Side by Side Compact */}
|
{/* Top Picks and Avoid Section - Side by Side when both present */}
|
||||||
<div className="grid lg:grid-cols-2 gap-4">
|
<div className={`grid gap-4 ${recommendation.top_picks.length > 0 ? 'lg:grid-cols-2' : ''}`}>
|
||||||
{recommendation.top_picks.length > 0 && (
|
{recommendation.top_picks.length > 0 && (
|
||||||
<TopPicks picks={recommendation.top_picks} />
|
<TopPicks picks={recommendation.top_picks} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -561,10 +561,10 @@ export default function Dashboard() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('ALL')}
|
onClick={() => setFilter('ALL')}
|
||||||
aria-pressed={filter === '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'
|
filter === 'ALL'
|
||||||
? 'bg-nifty-600 text-white shadow-sm'
|
? '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})
|
All ({total})
|
||||||
|
|
@ -572,10 +572,10 @@ export default function Dashboard() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('BUY')}
|
onClick={() => setFilter('BUY')}
|
||||||
aria-pressed={filter === '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'
|
filter === 'BUY'
|
||||||
? 'bg-green-600 text-white shadow-sm'
|
? '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})
|
Buy ({buy})
|
||||||
|
|
@ -583,10 +583,10 @@ export default function Dashboard() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('HOLD')}
|
onClick={() => setFilter('HOLD')}
|
||||||
aria-pressed={filter === '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'
|
filter === 'HOLD'
|
||||||
? 'bg-amber-600 text-white shadow-sm'
|
? '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})
|
Hold ({hold})
|
||||||
|
|
@ -594,10 +594,10 @@ export default function Dashboard() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('SELL')}
|
onClick={() => setFilter('SELL')}
|
||||||
aria-pressed={filter === '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'
|
filter === 'SELL'
|
||||||
? 'bg-red-600 text-white shadow-sm'
|
? '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})
|
Sell ({sell})
|
||||||
|
|
@ -605,7 +605,7 @@ export default function Dashboard() {
|
||||||
<span className="mx-0.5 text-gray-300 dark:text-gray-600">|</span>
|
<span className="mx-0.5 text-gray-300 dark:text-gray-600">|</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSortBy(sortBy === 'rank' ? 'symbol' : 'rank')}
|
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'
|
sortBy === 'rank'
|
||||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
|
? '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'
|
: '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..."
|
placeholder="Search by symbol or company name..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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 && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -637,7 +637,7 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
</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) => {
|
{filteredItems.map((item) => {
|
||||||
// COMPLETED with analysis data: clickable link
|
// COMPLETED with analysis data: clickable link
|
||||||
if (item.liveState === 'completed' && item.analysis) {
|
if (item.liveState === 'completed' && item.analysis) {
|
||||||
|
|
@ -645,14 +645,14 @@ export default function Dashboard() {
|
||||||
<Link
|
<Link
|
||||||
key={item.symbol}
|
key={item.symbol}
|
||||||
to={`/stock/${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"
|
role="listitem"
|
||||||
>
|
>
|
||||||
<div className="relative z-10">
|
<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" />
|
<RankBadge rank={item.analysis.rank} size="small" />
|
||||||
<span className="font-semibold text-sm text-gray-900 dark:text-gray-100">{item.symbol}</span>
|
<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>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{item.company_name}</p>
|
<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' && (
|
{item.analysis.hold_days != null && item.analysis.hold_days > 0 && item.analysis.decision !== 'SELL' && (
|
||||||
|
|
@ -755,7 +755,7 @@ export default function Dashboard() {
|
||||||
{/* Compact CTA */}
|
{/* Compact CTA */}
|
||||||
<Link
|
<Link
|
||||||
to="/history"
|
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={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #0284c7, #0369a1)',
|
background: 'linear-gradient(135deg, #0284c7, #0369a1)',
|
||||||
boxShadow: '0 2px 8px rgba(2, 132, 199, 0.25)',
|
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 { 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 { 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 type { ReturnBreakdown } from '../types';
|
||||||
|
import { NIFTY_50_STOCKS } from '../types';
|
||||||
import { DecisionBadge, HoldDaysBadge, RankBadge } from '../components/StockCard';
|
import { DecisionBadge, HoldDaysBadge, RankBadge } from '../components/StockCard';
|
||||||
import Sparkline from '../components/Sparkline';
|
import Sparkline from '../components/Sparkline';
|
||||||
import AccuracyExplainModal from '../components/AccuracyExplainModal';
|
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">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<RankBadge rank={stock.rank} size="small" />
|
<RankBadge rank={stock.rank} size="small" />
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{stock.symbol}</span>
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<DecisionBadge decision={stock.decision} size="small" />
|
<DecisionBadge decision={stock.decision} size="small" />
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,21 @@ import { api } from '../services/api';
|
||||||
import { useSettings } from '../contexts/SettingsContext';
|
import { useSettings } from '../contexts/SettingsContext';
|
||||||
import type { FullPipelineData, PipelineStep, PipelineStepStatus } from '../types/pipeline';
|
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
|
// Type for real backtest data from API
|
||||||
interface BacktestResult {
|
interface BacktestResult {
|
||||||
date: string;
|
date: string;
|
||||||
|
|
@ -940,17 +955,31 @@ export default function StockDetail() {
|
||||||
</div>
|
</div>
|
||||||
) : backtestResults.length > 0 ? (
|
) : backtestResults.length > 0 ? (
|
||||||
<div className="divide-y divide-gray-100 dark:divide-slate-700 max-h-[320px] overflow-y-auto">
|
<div className="divide-y divide-gray-100 dark:divide-slate-700 max-h-[320px] overflow-y-auto">
|
||||||
{backtestResults.map((entry, idx) => (
|
{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">
|
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 */}
|
{/* Date */}
|
||||||
<div className="w-16 flex-shrink-0">
|
<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', {
|
{new Date(entry.date).toLocaleDateString('en-IN', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
})}
|
})}
|
||||||
</div>
|
</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' })}
|
{new Date(entry.date).toLocaleDateString('en-IN', { weekday: 'short' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1005,7 +1034,8 @@ export default function StockDetail() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : history.length > 0 ? (
|
) : history.length > 0 ? (
|
||||||
<div className="p-8 text-center">
|
<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">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<TrendingUp className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
<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="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">
|
<span className="text-sm text-green-700 dark:text-green-400 line-clamp-3">
|
||||||
{latestRecommendation.top_picks.find(p => p.symbol === symbol)?.reason?.replace(/\*\*/g, '').replace(/\*/g, '')}
|
{cleanReason(latestRecommendation.top_picks.find(p => p.symbol === symbol)?.reason)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
<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="font-semibold text-red-800 dark:text-red-300 text-sm">Avoid: </span>
|
||||||
<span className="text-sm text-red-700 dark:text-red-400">
|
<span className="text-sm text-red-700 dark:text-red-400 line-clamp-3">
|
||||||
{latestRecommendation.stocks_to_avoid.find(s => s.symbol === symbol)?.reason?.replace(/\*\*/g, '').replace(/\*/g, '')}
|
{cleanReason(latestRecommendation.stocks_to_avoid.find(s => s.symbol === symbol)?.reason)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 |