SoftTraderFrontend/app/complex-chart/page.tsx
2026-03-20 11:10:13 -04:00

367 lines
11 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { Suspense, useState, useEffect, useMemo } from 'react'
import { CandlestickGraph } from '@/components/candlestick-chart'
// Timeframe options
const TIMEFRAMES = ['Custom', '1D', '5D', '10D', '1M', '3M', '6M', 'YTD', '1Y', '2Y', '3Y'] as const
type Timeframe = typeof TIMEFRAMES[number]
// Bar size options per timeframe — each bar size must be able to cover the
// visible range within IB API max duration limits:
// 1m→1D, 2m→2D, 5m→1W, 15m→2W, 30m/1h/2h/4h→1M, 1D/1W/1M→1Y+
const BAR_SIZE_MAP: Record<Exclude<Timeframe, 'Custom'>, string[]> = {
'1D': ['1m', '2m', '5m', '15m'],
'5D': ['5m', '15m', '30m', '1h'],
'10D': ['15m', '30m', '1h', '2h'],
'1M': ['30m', '1h', '4h', '1D'],
'3M': ['1D', '1W'],
'6M': ['1D', '1W'],
'YTD': ['1D', '1W'],
'1Y': ['1D', '1W'],
'2Y': ['1D', '1W', '1M'],
'3Y': ['1W', '1M'],
}
// Default bar size for each timeframe
const DEFAULT_BAR_SIZE: Record<Exclude<Timeframe, 'Custom'>, string> = {
'1D': '5m',
'5D': '30m',
'10D': '1h',
'1M': '1D',
'3M': '1D',
'6M': '1D',
'YTD': '1D',
'1Y': '1D',
'2Y': '1W',
'3Y': '1W',
}
/**
* Check if a date is a trading day (Mon-Fri, simplified - doesn't account for holidays)
*/
function isTradingDay(date: Date): boolean {
const day = date.getDay()
return day >= 1 && day <= 5
}
/**
* Get the last trading day (today if market open, otherwise previous trading day)
*/
function getLastTradingDay(): Date {
const today = new Date()
today.setHours(0, 0, 0, 0)
while (!isTradingDay(today)) {
today.setDate(today.getDate() - 1)
}
return today
}
/**
* Go back N trading days from a given date
*/
function goBackTradingDays(fromDate: Date, tradingDays: number): Date {
const result = new Date(fromDate)
let daysToGoBack = tradingDays
while (daysToGoBack > 0) {
result.setDate(result.getDate() - 1)
if (isTradingDay(result)) {
daysToGoBack--
}
}
return result
}
/**
* Calculate start date based on timeframe from end date
* For day-based timeframes (1D, 5D, 10D), counts back trading days only
*/
function calculateStartDate(timeframe: Timeframe, endDate: Date): Date {
const start = new Date(endDate)
switch (timeframe) {
case '1D':
// Same day - no change
return start
case '5D':
// Go back 4 trading days (5 days total including end date)
return goBackTradingDays(start, 4)
case '10D':
// Go back 9 trading days (10 days total including end date)
return goBackTradingDays(start, 9)
case '1M':
start.setMonth(start.getMonth() - 1)
break
case '3M':
start.setMonth(start.getMonth() - 3)
break
case '6M':
start.setMonth(start.getMonth() - 6)
break
case 'YTD':
start.setMonth(0)
start.setDate(1)
break
case '1Y':
start.setFullYear(start.getFullYear() - 1)
break
case '2Y':
start.setFullYear(start.getFullYear() - 2)
break
case '3Y':
start.setFullYear(start.getFullYear() - 3)
break
default:
break
}
return start
}
/**
* Count trading days between two dates (simplified - Mon-Fri only)
*/
function countTradingDays(start: Date, end: Date): number {
let count = 0
const current = new Date(start)
while (current <= end) {
if (isTradingDay(current)) {
count++
}
current.setDate(current.getDate() + 1)
}
return count
}
/**
* Get bar size options for custom timeframe based on date range.
* Thresholds match IB API max duration limits per bar size:
* 5m→1W(5 trading), 15m→2W(10), 30m/1h/2h/4h→1M(~20), 1D+→1Y+
*/
function getCustomBarSizeOptions(startDate: Date, endDate: Date): { options: string[], default: string } {
const tradingDays = countTradingDays(startDate, endDate)
if (tradingDays <= 1) {
return { options: ['1m', '2m', '5m', '15m'], default: '5m' }
} else if (tradingDays <= 5) {
return { options: ['5m', '15m', '30m', '1h'], default: '15m' }
} else if (tradingDays <= 10) {
return { options: ['15m', '30m', '1h', '2h'], default: '30m' }
} else if (tradingDays <= 20) {
return { options: ['30m', '1h', '4h', '1D'], default: '1D' }
} else {
return { options: ['1D', '1W'], default: '1D' }
}
}
/**
* Format date to YYYY-MM-DD for input.
* Uses local-time methods to match how parseInputDate creates dates,
* avoiding the day-shift that toISOString() causes in non-UTC timezones.
*/
function formatDateForInput(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/**
* Parse date string to Date object
*/
function parseInputDate(dateStr: string): Date {
const [year, month, day] = dateStr.split('-').map(Number)
return new Date(year, month - 1, day)
}
function ComplexChartContent() {
const searchParams = useSearchParams()
const symbol = searchParams.get('symbol') || 'AAPL'
// Initialize with last trading day
const lastTradingDay = useMemo(() => getLastTradingDay(), [])
const [timeframe, setTimeframe] = useState<Timeframe>('5D')
const [barSize, setBarSize] = useState<string>('30m')
const [endDate, setEndDate] = useState<string>(() => formatDateForInput(lastTradingDay))
const [startDate, setStartDate] = useState<string>(() => {
const start = calculateStartDate('5D', lastTradingDay)
return formatDateForInput(start)
})
const [savedChart, setSavedChart] = useState('')
// Get available bar sizes based on current timeframe/dates
const barSizeOptions = useMemo(() => {
if (timeframe === 'Custom') {
const start = parseInputDate(startDate)
const end = parseInputDate(endDate)
return getCustomBarSizeOptions(start, end)
}
return {
options: BAR_SIZE_MAP[timeframe],
default: DEFAULT_BAR_SIZE[timeframe]
}
}, [timeframe, startDate, endDate])
// Update dates when timeframe changes (except for Custom)
useEffect(() => {
if (timeframe !== 'Custom') {
const end = parseInputDate(endDate)
const start = calculateStartDate(timeframe, end)
setStartDate(formatDateForInput(start))
// Update bar size to default for this timeframe
setBarSize(DEFAULT_BAR_SIZE[timeframe])
}
}, [timeframe, endDate])
// When custom dates change, update bar size if current is invalid
useEffect(() => {
if (timeframe === 'Custom') {
if (!barSizeOptions.options.includes(barSize)) {
setBarSize(barSizeOptions.default)
}
}
}, [timeframe, barSizeOptions, barSize])
// Handle timeframe change
const handleTimeframeChange = (newTimeframe: Timeframe) => {
setTimeframe(newTimeframe)
}
// Handle end date change
const handleEndDateChange = (newEndDate: string) => {
setEndDate(newEndDate)
if (timeframe !== 'Custom') {
const end = parseInputDate(newEndDate)
const start = calculateStartDate(timeframe, end)
setStartDate(formatDateForInput(start))
}
}
// Handle start date change (switches to Custom if not already)
const handleStartDateChange = (newStartDate: string) => {
setStartDate(newStartDate)
if (timeframe !== 'Custom') {
setTimeframe('Custom')
}
}
const handleGo = () => {
// Placeholder for Go button action
}
const handleSaveChart = () => {
// Placeholder for Save chart action
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="bg-background border-b border-border px-6 py-4 flex items-center gap-4">
<Link href={`/symbol-details?symbol=${symbol}`} className="p-2 hover:bg-secondary transition-colors">
<ArrowLeft size={20} />
</Link>
<h1 className="text-2xl font-bold">Chart</h1>
</div>
{/* Toolbar */}
<div className="bg-card border-b border-border px-6 py-3 flex items-center justify-between gap-4">
{/* Left side */}
<div className="flex items-center gap-3">
<select
value={timeframe}
onChange={(e) => handleTimeframeChange(e.target.value as Timeframe)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer min-w-28"
>
{TIMEFRAMES.map((tf) => (
<option key={tf} value={tf}>{tf}</option>
))}
</select>
<select
value={barSize}
onChange={(e) => setBarSize(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer min-w-28"
>
{barSizeOptions.options.map((bs) => (
<option key={bs} value={bs}>{bs}</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer"
/>
<input
type="date"
value={endDate}
onChange={(e) => handleEndDateChange(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer"
/>
<button
onClick={handleGo}
className="bg-primary text-primary-foreground text-sm font-medium px-4 py-1.5 hover:bg-primary/90 transition-colors"
>
Go
</button>
</div>
{/* Right side */}
<div className="flex items-center gap-3">
<select
value={savedChart}
onChange={(e) => setSavedChart(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer min-w-36"
>
<option value="">My Saved Charts</option>
</select>
<button
onClick={handleSaveChart}
className="bg-secondary text-secondary-foreground text-sm font-medium px-4 py-1.5 hover:bg-secondary/80 transition-colors border border-border"
>
Save Chart
</button>
</div>
</div>
{/* Chart area */}
<div className="flex-1 overflow-hidden bg-background p-4">
<CandlestickGraph
symbol={symbol}
user="frontend"
startDate={startDate}
endDate={endDate}
barSize={barSize}
size="full"
/>
</div>
</div>
)
}
export default function ComplexChart() {
return (
<Suspense fallback={
<div className="h-full flex items-center justify-center bg-background">
<div className="text-muted-foreground">Loading...</div>
</div>
}>
<ComplexChartContent />
</Suspense>
)
}