borsa / nextjs-app /src /components /PositionChart.tsx
veteroner's picture
feat: live position monitoring with charts + trading system production ready
656ac31
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ReferenceDot,
ResponsiveContainer,
} from 'recharts'
import { TrendingUp, TrendingDown, RefreshCw, Eye } from 'lucide-react'
// ─── Types ────────────────────────────────
interface PricePoint {
date: string
open: number
close: number
high: number
low: number
volume: number
}
interface TradeMarker {
type: 'BUY' | 'SELL'
date: string
price: number
}
interface PositionChartProps {
symbol: string
entryPrice: number
entryDate: string
quantity: number
/** Closed trades to show SELL markers */
exitPrice?: number
exitDate?: string
exitReason?: string
/** Polling intervals in ms */
priceRefreshMs?: number
chartRefreshMs?: number
}
// ─── Formatting ───────────────────────────
function fmtMoney(n: number) {
return new Intl.NumberFormat('tr-TR', {
style: 'currency',
currency: 'TRY',
minimumFractionDigits: 2,
}).format(n)
}
// ─── Custom Tooltip ───────────────────────
function ChartTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value: number }>; label?: string }) {
if (!active || !payload?.length) return null
return (
<div className="bg-gray-900 text-white text-xs px-3 py-2 rounded-lg shadow-lg border border-gray-700">
<p className="text-gray-400 mb-1">{label}</p>
<p className="font-bold">{fmtMoney(payload[0].value)}</p>
</div>
)
}
// ─── BUY/SELL Marker Shape ────────────────
function BuyMarker(props: { cx?: number; cy?: number }) {
const { cx = 0, cy = 0 } = props
return (
<g>
{/* Green upward triangle */}
<polygon
points={`${cx},${cy - 12} ${cx - 8},${cy + 4} ${cx + 8},${cy + 4}`}
fill="#22c55e"
stroke="#fff"
strokeWidth={1.5}
/>
<text x={cx} y={cy - 16} textAnchor="middle" fill="#22c55e" fontSize={9} fontWeight="bold">
AL
</text>
</g>
)
}
function SellMarker(props: { cx?: number; cy?: number }) {
const { cx = 0, cy = 0 } = props
return (
<g>
{/* Red downward triangle */}
<polygon
points={`${cx},${cy + 12} ${cx - 8},${cy - 4} ${cx + 8},${cy - 4}`}
fill="#ef4444"
stroke="#fff"
strokeWidth={1.5}
/>
<text x={cx} y={cy + 24} textAnchor="middle" fill="#ef4444" fontSize={9} fontWeight="bold">
SAT
</text>
</g>
)
}
// ─── Main Component ───────────────────────
export default function PositionChart({
symbol,
entryPrice,
entryDate,
quantity,
exitPrice,
exitDate,
exitReason,
priceRefreshMs = 10_000,
chartRefreshMs = 60_000,
}: PositionChartProps) {
const [priceData, setPriceData] = useState<PricePoint[]>([])
const [currentPrice, setCurrentPrice] = useState<number | null>(null)
const [prevPrice, setPrevPrice] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [lastUpdate, setLastUpdate] = useState<string>('')
const [expanded, setExpanded] = useState(true)
const mountedRef = useRef(true)
const isClosed = !!exitDate
// ─── Fetch full chart data ─────────────
const fetchChart = useCallback(async () => {
try {
const res = await fetch(`/api/stocks/${symbol}/prices?days=30&interval=1d`, {
credentials: 'include',
})
const json = await res.json()
if (!mountedRef.current) return
if (json.ok && json.data?.length > 0) {
setPriceData(json.data)
const last = json.data[json.data.length - 1]
if (last) {
setPrevPrice(currentPrice)
setCurrentPrice(last.close)
}
}
} catch {
// silently fail, will retry
} finally {
if (mountedRef.current) setLoading(false)
}
}, [symbol]) // eslint-disable-line react-hooks/exhaustive-deps
// ─── Fetch latest price (lightweight) ──
const fetchPrice = useCallback(async () => {
try {
const res = await fetch(`/api/stocks/${symbol}/prices?days=1&interval=1d`, {
credentials: 'include',
})
const json = await res.json()
if (!mountedRef.current) return
if (json.ok && json.data?.length > 0) {
const last = json.data[json.data.length - 1]
setPrevPrice(currentPrice)
setCurrentPrice(last.close)
setLastUpdate(new Date().toLocaleTimeString('tr-TR'))
}
} catch {
// silently fail
}
}, [symbol]) // eslint-disable-line react-hooks/exhaustive-deps
// ─── Initial load + chart refresh ──────
useEffect(() => {
mountedRef.current = true
fetchChart()
if (chartRefreshMs > 0) {
const interval = setInterval(fetchChart, chartRefreshMs)
return () => {
mountedRef.current = false
clearInterval(interval)
}
}
return () => { mountedRef.current = false }
}, [fetchChart, chartRefreshMs])
// ─── Price polling (only for open positions) ──
useEffect(() => {
if (isClosed || priceRefreshMs <= 0) return
const interval = setInterval(fetchPrice, priceRefreshMs)
return () => clearInterval(interval)
}, [fetchPrice, priceRefreshMs, isClosed])
// ─── Calculations ──────────────────────
const displayPrice = isClosed ? (exitPrice ?? entryPrice) : (currentPrice ?? entryPrice)
const pnl = (displayPrice - entryPrice) * quantity
const pnlPct = ((displayPrice - entryPrice) / entryPrice) * 100
const totalValue = displayPrice * quantity
const priceDirection = prevPrice !== null && currentPrice !== null
? currentPrice > prevPrice ? 'up' : currentPrice < prevPrice ? 'down' : 'same'
: 'same'
// ─── Find marker positions in data ─────
const entryDateShort = entryDate.slice(0, 10)
const exitDateShort = exitDate?.slice(0, 10)
const entryPointIdx = priceData.findIndex(d => d.date >= entryDateShort)
const exitPointIdx = exitDateShort ? priceData.findIndex(d => d.date >= exitDateShort) : -1
// ─── Stop loss / take profit lines ─────
const stopLoss = entryPrice * 0.95
const takeProfit = entryPrice * 1.10
if (loading) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="h-6 w-20 bg-gray-200 rounded" />
<div className="h-4 w-32 bg-gray-100 rounded" />
</div>
<div className="h-48 bg-gray-50 rounded mt-3" />
</div>
)
}
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{/* ─── Header ─────────────────────── */}
<div
className="px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
<h4 className="font-bold text-lg text-gray-900">{symbol}</h4>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
isClosed
? 'bg-gray-100 text-gray-600'
: 'bg-green-50 text-green-700 border border-green-200'
}`}>
{isClosed ? `KapalΔ± β€” ${exitReason || ''}` : 'AΓ§Δ±k Pozisyon'}
</span>
{!isClosed && lastUpdate && (
<span className="text-xs text-gray-400 flex items-center gap-1">
<Eye className="w-3 h-3" />
{lastUpdate}
</span>
)}
</div>
<div className="flex items-center gap-4">
{/* Current Price with animation */}
<div className="text-right">
<div className={`text-xl font-bold tabular-nums transition-colors duration-300 ${
priceDirection === 'up' ? 'text-green-600' :
priceDirection === 'down' ? 'text-red-600' :
'text-gray-900'
}`}>
{displayPrice.toFixed(2)} β‚Ί
{priceDirection === 'up' && <TrendingUp className="w-4 h-4 inline ml-1" />}
{priceDirection === 'down' && <TrendingDown className="w-4 h-4 inline ml-1" />}
</div>
<div className={`text-sm font-medium ${pnl >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{pnl >= 0 ? '+' : ''}{fmtMoney(pnl)} ({pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%)
</div>
</div>
</div>
</div>
{/* ─── Expanded Content ───────────── */}
{expanded && (
<div className="border-t border-gray-100">
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 px-4 py-3 bg-gray-50/50 text-sm">
<div>
<span className="text-gray-500">Adet</span>
<p className="font-semibold text-gray-900">{quantity.toLocaleString('tr-TR')} lot</p>
</div>
<div>
<span className="text-gray-500">Giriş Fiyatı</span>
<p className="font-semibold text-gray-900">{entryPrice.toFixed(2)} β‚Ί</p>
</div>
<div>
<span className="text-gray-500">Toplam Değer</span>
<p className="font-semibold text-gray-900">{fmtMoney(totalValue)}</p>
</div>
<div>
<span className="text-gray-500">Stop Loss</span>
<p className="font-semibold text-red-600">{stopLoss.toFixed(2)} β‚Ί</p>
</div>
<div>
<span className="text-gray-500">Take Profit</span>
<p className="font-semibold text-green-600">{takeProfit.toFixed(2)} β‚Ί</p>
</div>
</div>
{/* Chart */}
{priceData.length > 0 ? (
<div className="px-4 py-3">
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={priceData} margin={{ top: 20, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id={`gradient-${symbol}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={pnl >= 0 ? '#22c55e' : '#ef4444'} stopOpacity={0.3} />
<stop offset="100%" stopColor={pnl >= 0 ? '#22c55e' : '#ef4444'} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#9ca3af' }}
tickFormatter={(v: string) => v.slice(5)}
interval="preserveStartEnd"
/>
<YAxis
domain={['auto', 'auto']}
tick={{ fontSize: 10, fill: '#9ca3af' }}
tickFormatter={(v: number) => v.toFixed(1)}
width={55}
/>
<Tooltip content={<ChartTooltip />} />
{/* Price area */}
<Area
type="monotone"
dataKey="close"
stroke={pnl >= 0 ? '#22c55e' : '#ef4444'}
strokeWidth={2}
fill={`url(#gradient-${symbol})`}
dot={false}
animationDuration={500}
/>
{/* Entry price reference line */}
<ReferenceLine
y={entryPrice}
stroke="#f59e0b"
strokeDasharray="6 4"
strokeWidth={1.5}
label={{
value: `Giriş: ${entryPrice.toFixed(2)}`,
position: 'right',
fill: '#f59e0b',
fontSize: 10,
}}
/>
{/* Stop loss line */}
{!isClosed && (
<ReferenceLine
y={stopLoss}
stroke="#ef4444"
strokeDasharray="4 4"
strokeWidth={1}
strokeOpacity={0.5}
/>
)}
{/* Take profit line */}
{!isClosed && (
<ReferenceLine
y={takeProfit}
stroke="#22c55e"
strokeDasharray="4 4"
strokeWidth={1}
strokeOpacity={0.5}
/>
)}
{/* BUY marker */}
{entryPointIdx >= 0 && (
<ReferenceDot
x={priceData[entryPointIdx].date}
y={priceData[entryPointIdx].close}
r={0}
shape={<BuyMarker />}
/>
)}
{/* SELL marker */}
{exitPointIdx >= 0 && exitPrice && (
<ReferenceDot
x={priceData[exitPointIdx].date}
y={priceData[exitPointIdx].close}
r={0}
shape={<SellMarker />}
/>
)}
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="px-4 py-8 text-center text-gray-400 text-sm">
Fiyat verisi bulunamadΔ±
</div>
)}
</div>
)}
</div>
)
}