367 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|