Fixed date issues.

This commit is contained in:
caspar 2026-03-21 09:26:43 -04:00
parent 413cd17472
commit 7179cd3842
7 changed files with 157 additions and 145 deletions

View File

@ -1,3 +1,72 @@
# SoftTraderFrontend # SoftTraderFrontend
The frontend for a lite Bloomberg terminal. The frontend for a lite Bloomberg terminal.
## Features
- **Dashboard** — Live clock, NYSE market status indicator (open/premarket/after-hours/closed), rotating stock cards for watched symbols, and news headlines
- **Watchlist** — Manage a personal list of stock symbols and topics, with swipeable rows and quick navigation to details
- **Symbol Details** — Live quotes with price change, volume, open/close data, collapsible stock chart (1D–3Y), news headlines with sentiment analysis, and configurable price alerts
- **Advanced Candlestick Chart** — Full candlestick charting with configurable timeframes and bar sizes, custom date ranges, and interactive crosshair
- **News & Sentiment** — Per-symbol news feed with pull-to-refresh and an aggregated sentimentality bar
- **Weather Widget** — Current conditions with configurable location and temperature unit (F/C)
- **Settings** — Dark/light theme toggle, weather location, temperature unit, 12h/24h time format
## Tech Stack
- **Framework:** [Next.js 16](https://nextjs.org/) (App Router)
- **UI:** [React 19](https://react.dev/), [Tailwind CSS 4](https://tailwindcss.com/), [shadcn/ui](https://ui.shadcn.com/) (Radix primitives)
- **Charts:** [Recharts](https://recharts.org/) (line/area), custom D3-based candlestick chart
- **Backend API:** `api.dongfeng-systems.org` (quotes, news, user interests, weather)
- **Analytics:** Vercel Analytics
- **Language:** TypeScript
## Project Structure
```
app/ # Next.js App Router pages
page.tsx # Home / dashboard
interests/ # Watchlist
symbol-details/ # Individual stock detail view
complex-chart/ # Advanced candlestick chart
interest-details/ # Topic/interest news feed
add-item/ # Add symbol or topic
settings/ # User settings
components/
layout/ # StockClockLayout, Sidebar
pages/ # Page-level components (FrontPage, InterestsPage, SettingsPage)
widgets/ # StockCard, NewsHeadlines, WeatherWidget, SentimentalityBar, etc.
stock-chart/ # Line/area stock chart
candlestick-chart/ # D3-based candlestick chart
ui/ # shadcn/ui primitives
providers/ # ThemeProvider
lib/ # API clients and utilities
stockApi.ts # Live quotes, historical data
newsApi.ts # News feed
userInterestsApi.ts # Watchlist/interests CRUD
weatherApi.ts # Weather data and preferences
```
## Getting Started
### Prerequisites
- Node.js 18+
### Install and Run
```bash
npm install
npm run dev
```
The app will be available at [http://localhost:3000](http://localhost:3000).
### Scripts
| Command | Description |
| --------------- | ------------------------ |
| `npm run dev` | Start development server |
| `npm run build` | Production build |
| `npm run start` | Start production server |
| `npm run lint` | Run ESLint |

View File

@ -10,17 +10,15 @@ import { CandlestickGraph } from '@/components/candlestick-chart'
const TIMEFRAMES = ['Custom', '1D', '5D', '10D', '1M', '3M', '6M', 'YTD', '1Y', '2Y', '3Y'] as const const TIMEFRAMES = ['Custom', '1D', '5D', '10D', '1M', '3M', '6M', 'YTD', '1Y', '2Y', '3Y'] as const
type Timeframe = typeof TIMEFRAMES[number] type Timeframe = typeof TIMEFRAMES[number]
// Bar size options per timeframe — each bar size must be able to cover the // Bar size options per timeframe
// 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[]> = { const BAR_SIZE_MAP: Record<Exclude<Timeframe, 'Custom'>, string[]> = {
'1D': ['1m', '2m', '5m', '15m'], '1D': ['1m', '2m', '5m', '15m'],
'5D': ['5m', '15m', '30m', '1h'], '5D': ['5m', '15m', '30m', '1h'],
'10D': ['15m', '30m', '1h', '2h'], '10D': ['15m', '30m', '1h', '2h'],
'1M': ['30m', '1h', '4h', '1D'], '1M': ['30m', '1h', '4h', '1D'],
'3M': ['1D', '1W'], '3M': ['1h', '4h', '1D', '1W'],
'6M': ['1D', '1W'], '6M': ['4h', '1D', '1W'],
'YTD': ['1D', '1W'], 'YTD': ['4h', '1D', '1W'],
'1Y': ['1D', '1W'], '1Y': ['1D', '1W'],
'2Y': ['1D', '1W', '1M'], '2Y': ['1D', '1W', '1M'],
'3Y': ['1W', '1M'], '3Y': ['1W', '1M'],
@ -142,36 +140,33 @@ function countTradingDays(start: Date, end: Date): number {
} }
/** /**
* Get bar size options for custom timeframe based on date range. * 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 } { function getCustomBarSizeOptions(startDate: Date, endDate: Date): { options: string[], default: string } {
const tradingDays = countTradingDays(startDate, endDate) const tradingDays = countTradingDays(startDate, endDate)
if (tradingDays <= 1) { if (tradingDays <= 1) {
return { options: ['1m', '2m', '5m', '15m'], default: '5m' } return { options: ['1m', '2m', '5m', '15m'], default: '5m' }
} else if (tradingDays <= 5) { } else if (tradingDays <= 5) {
return { options: ['5m', '15m', '30m', '1h'], default: '15m' } return { options: ['5m', '15m', '30m', '1h'], default: '30m' }
} else if (tradingDays <= 10) {
return { options: ['15m', '30m', '1h', '2h'], default: '30m' }
} else if (tradingDays <= 20) { } else if (tradingDays <= 20) {
return { options: ['30m', '1h', '4h', '1D'], default: '1D' } return { options: ['15m', '30m', '1h', '4h'], default: '1h' }
} else if (tradingDays <= 90) {
return { options: ['1h', '4h', '1D'], default: '1D' }
} else if (tradingDays <= 365) {
return { options: ['4h', '1D', '1W'], default: '1D' }
} else if (tradingDays <= 365 * 3) {
return { options: ['1D', '1W', '1M'], default: '1W' }
} else { } else {
return { options: ['1D', '1W'], default: '1D' } return { options: ['1W', '1M'], default: '1M' }
} }
} }
/** /**
* Format date to YYYY-MM-DD for input. * 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 { function formatDateForInput(date: Date): string {
const y = date.getFullYear() return date.toISOString().split('T')[0]
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
} }
/** /**
@ -196,7 +191,6 @@ function ComplexChartContent() {
const start = calculateStartDate('5D', lastTradingDay) const start = calculateStartDate('5D', lastTradingDay)
return formatDateForInput(start) return formatDateForInput(start)
}) })
const [savedChart, setSavedChart] = useState('')
// Get available bar sizes based on current timeframe/dates // Get available bar sizes based on current timeframe/dates
const barSizeOptions = useMemo(() => { const barSizeOptions = useMemo(() => {
@ -255,14 +249,6 @@ function ComplexChartContent() {
} }
} }
const handleGo = () => {
// Placeholder for Go button action
}
const handleSaveChart = () => {
// Placeholder for Save chart action
}
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}
@ -310,31 +296,6 @@ function ComplexChartContent() {
onChange={(e) => handleEndDateChange(e.target.value)} onChange={(e) => handleEndDateChange(e.target.value)}
className="border border-border bg-input text-foreground text-sm px-3 py-1.5 cursor-pointer" 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>
</div> </div>

View File

@ -153,10 +153,10 @@ function SymbolDetailsContent() {
))} ))}
</div> </div>
{/* Chart */} {/* Chart will be embedded here */}
<div className="p-4"> <div className="p-4">
<StockChart symbol={symbol} range={selectedRange} /> <StockChart symbol={symbol} range={selectedRange} />
</div> </div>
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<Link <Link

View File

@ -830,19 +830,20 @@ export function CandlestickGraph({
)} )}
</svg> </svg>
{/* Notes overlay */} {/* Notes overlay */}
{notes.map((note) => { {notes.map((note) => {
// Find the candle index for this note's time // Check if note is within the visible time range
const candleIndex = candles.findIndex(c => c.time === note.time); if (candles.length === 0) return null;
if (candleIndex === -1) return null;
const startCandle = candles[viewModel.visibleStartIndex];
// Check if note is in visible range const endCandle = candles[viewModel.visibleEndIndex];
if (candleIndex < viewModel.visibleStartIndex || candleIndex > viewModel.visibleEndIndex) {
return null; if (!startCandle || !endCandle) return null;
} if (note.time < startCandle.time || note.time > endCandle.time) return null;
const x = getXForIndex(candleIndex); // Use interpolated time positioning (works across all bar sizes)
const y = getYForPrice(note.price); const x = getXForTime(note.time);
const y = getYForPrice(note.price);
return ( return (
<NoteComponent <NoteComponent

View File

@ -54,94 +54,79 @@ function toDate(date: string | Date): Date {
} }
/** /**
* Checks if two dates are the same calendar day (UTC). * Checks if two dates are the same calendar day.
* Must use UTC methods because date strings like "2026-03-20" parse as UTC midnight.
*/ */
function isSameDay(date1: Date, date2: Date): boolean { function isSameDay(date1: Date, date2: Date): boolean {
return ( return (
date1.getUTCFullYear() === date2.getUTCFullYear() && date1.getFullYear() === date2.getFullYear() &&
date1.getUTCMonth() === date2.getUTCMonth() && date1.getMonth() === date2.getMonth() &&
date1.getUTCDate() === date2.getUTCDate() date1.getDate() === date2.getDate()
); );
} }
/** /**
* Calculates the number of trading days between two dates (excludes weekends). * Calculates the number of trading days between two dates (excludes weekends).
* Uses UTC methods because date strings parse as UTC midnight — local-time
* methods would shift the day-of-week check in non-UTC timezones.
*/ */
function calculateTradingDays(startDate: Date, endDate: Date): number { function calculateTradingDays(startDate: Date, endDate: Date): number {
let tradingDays = 0; let tradingDays = 0;
const current = new Date(startDate); const current = new Date(startDate);
while (current <= endDate) { while (current <= endDate) {
const dayOfWeek = current.getUTCDay(); const dayOfWeek = current.getDay();
// 0 = Sunday, 6 = Saturday // 0 = Sunday, 6 = Saturday
if (dayOfWeek !== 0 && dayOfWeek !== 6) { if (dayOfWeek !== 0 && dayOfWeek !== 6) {
tradingDays++; tradingDays++;
} }
current.setUTCDate(current.getUTCDate() + 1); current.setDate(current.getDate() + 1);
} }
return tradingDays; return tradingDays;
} }
/** /**
* IB API maximum duration in calendar days per bar size. * Converts trading days to API duration format.
* Durations exceeding these limits cause 400 errors.
*/
function getMaxCalendarDays(barSize: string): number {
switch (barSize) {
case '1m': return 1;
case '2m': return 2;
case '5m': return 7;
case '15m': return 14;
case '30m': return 30;
case '1h': return 30;
case '2h': return 30;
case '4h': return 30;
default: return 3650; // daily+ bars support multi-year requests
}
}
/**
* Converts trading days to API duration format, clamped to IB API limits.
* Format: "{number}+{unit}" where unit is D (days), W (weeks), M (months), Y (years) * Format: "{number}+{unit}" where unit is D (days), W (weeks), M (months), Y (years)
* *
* The API duration uses calendar units, so trading days must be converted: * The API duration uses calendar units, so trading days must be converted:
* ~5 trading days = 1 calendar week, ~21 trading days = 1 calendar month. * ~5 trading days = 1 calendar week, ~21 trading days = 1 calendar month.
*
* Uses W/M units for 30m+ intraday bars because the IB API can reject
* D-unit durations that exceed per-bar-size thresholds.
* Uses floor() when converting to larger units to avoid overshooting the cap.
*/ */
function tradingDaysToDuration(tradingDays: number, barSize: string): string { function tradingDaysToDuration(tradingDays: number, barSize: string): string {
if (tradingDays <= 0) { if (tradingDays <= 0) {
return '1+D'; return '1+D';
} }
// Convert trading days to calendar days and clamp to IB API limit // Convert trading days to calendar days (7 calendar days per 5 trading days)
let calendarDays = Math.ceil(tradingDays * 7 / 5); const calendarDays = Math.ceil(tradingDays * 7 / 5);
calendarDays = Math.min(calendarDays, getMaxCalendarDays(barSize));
// For 30m+ intraday bars, prefer W/M units over large D values // For 30m and hourly bars, prefer W/M units over large D values.
// The IB API can reject D-unit durations that exceed per-bar-size
// thresholds even when the equivalent W/M duration would succeed.
if (['30m', '1h', '2h', '4h'].includes(barSize)) { if (['30m', '1h', '2h', '4h'].includes(barSize)) {
if (calendarDays <= 6) return `${calendarDays}+D`; if (calendarDays <= 6) return `${calendarDays}+D`;
if (calendarDays <= 28) { const weeks = Math.ceil(calendarDays / 7);
return `${Math.floor(calendarDays / 7)}+W`; if (weeks <= 4) return `${weeks}+W`;
}
return '1+M'; return '1+M';
} }
// For other bars, use D when possible // For short durations, use days
if (calendarDays <= 365) { if (calendarDays <= 90) {
return `${calendarDays}+D`; return `${calendarDays}+D`;
} }
// Larger durations: use M or Y (floor to avoid overshooting the cap) // For medium durations, use weeks
const months = Math.floor(calendarDays / 30); const weeks = Math.ceil(calendarDays / 7);
if (months <= 12) return `${months}+M`; if (weeks <= 52) {
return `${Math.floor(months / 12)}+Y`; return `${weeks}+W`;
}
// For longer durations, use months or years
const months = Math.ceil(calendarDays / 30);
if (months <= 24) {
return `${months}+M`;
}
const years = Math.ceil(months / 12);
return `${years}+Y`;
} }
// Extra trading days fetched before the visible range so indicators (EMA, MACD, // Extra trading days fetched before the visible range so indicators (EMA, MACD,
@ -201,8 +186,12 @@ async function fetchCandlesFromAPI(
endDateTime = endOfDay.toISOString(); endDateTime = endOfDay.toISOString();
// Default to 5m bars for intraday if a daily/weekly bar size is specified // Default to 5m bars for intraday if a daily/weekly bar size is specified
const intradayBarSize = ['1D', '1W', '1M'].includes(barSize) ? '5m' : barSize; const intradayBarSize = ['1D', '1W', '1M'].includes(barSize) ? '5m' : barSize;
// Cap warmup to IB API max duration (e.g. 2m bars max 2D, 1m bars max 1D) // Cap warmup days to IB API max duration per bar size
duration = `${Math.min(3, getMaxCalendarDays(intradayBarSize))}+D`; // (e.g. 2m bars max 2D, 1m bars max 1D)
const maxDays: Record<string, number> = {
'1m': 1, '2m': 2, '5m': 7, '15m': 14, '30m': 28,
};
duration = `${Math.min(3, maxDays[intradayBarSize] ?? 3)}+D`;
apiBarSize = BAR_SIZE_MAP[intradayBarSize] || 'FiveMins'; apiBarSize = BAR_SIZE_MAP[intradayBarSize] || 'FiveMins';
} else { } else {
const tradingDays = calculateTradingDays(start, end); const tradingDays = calculateTradingDays(start, end);
@ -351,4 +340,4 @@ export function mergeCandles(existing: Candle[], incoming: Candle[]): Candle[] {
// Sort by time and return // Sort by time and return
return Array.from(existingMap.values()).sort((a, b) => a.time - b.time); return Array.from(existingMap.values()).sort((a, b) => a.time - b.time);
} }

View File

@ -32,10 +32,16 @@ function formatInLocalTime(
return new Intl.DateTimeFormat(undefined, opts).format(date) return new Intl.DateTimeFormat(undefined, opts).format(date)
} }
function getUtcDateKey(timestamp: string) { /**
* Get the trading day key for a timestamp based on US Eastern time
* (NYSE operates in Eastern time, so trading days should be determined by ET, not UTC)
*/
function getTradingDayKey(timestamp: string) {
const d = new Date(timestamp) const d = new Date(timestamp)
if (Number.isNaN(d.getTime())) return '' if (Number.isNaN(d.getTime())) return ''
return d.toISOString().slice(0, 10)
// Format in US Eastern time to get the correct trading day
return d.toLocaleDateString('en-CA', { timeZone: 'America/New_York' }) // 'en-CA' gives YYYY-MM-DD format
} }
function sortByTimestamp<T extends { timestamp: string }>(rows: T[]) { function sortByTimestamp<T extends { timestamp: string }>(rows: T[]) {
@ -46,18 +52,18 @@ function sortByTimestamp<T extends { timestamp: string }>(rows: T[]) {
function keepLatestTradingDay<T extends { timestamp: string }>(rows: T[]) { function keepLatestTradingDay<T extends { timestamp: string }>(rows: T[]) {
const sorted = sortByTimestamp(rows) const sorted = sortByTimestamp(rows)
const distinctDays = [...new Set(sorted.map((r) => getUtcDateKey(r.timestamp)).filter(Boolean))] const distinctDays = [...new Set(sorted.map((r) => getTradingDayKey(r.timestamp)).filter(Boolean))]
if (!distinctDays.length) return sorted if (!distinctDays.length) return sorted
const latest = distinctDays[distinctDays.length - 1] const latest = distinctDays[distinctDays.length - 1]
return sorted.filter((r) => getUtcDateKey(r.timestamp) === latest) return sorted.filter((r) => getTradingDayKey(r.timestamp) === latest)
} }
function keepLatestNTradingDays<T extends { timestamp: string }>(rows: T[], n: number) { function keepLatestNTradingDays<T extends { timestamp: string }>(rows: T[], n: number) {
const sorted = sortByTimestamp(rows) const sorted = sortByTimestamp(rows)
const distinctDays = [...new Set(sorted.map((r) => getUtcDateKey(r.timestamp)).filter(Boolean))] const distinctDays = [...new Set(sorted.map((r) => getTradingDayKey(r.timestamp)).filter(Boolean))]
const keep = new Set(distinctDays.slice(-n)) const keep = new Set(distinctDays.slice(-n))
return sorted.filter((r) => keep.has(getUtcDateKey(r.timestamp))) return sorted.filter((r) => keep.has(getTradingDayKey(r.timestamp)))
} }
function normalizeRawRows(input: unknown[]): RawHistoricalLinePoint[] { function normalizeRawRows(input: unknown[]): RawHistoricalLinePoint[] {

View File

@ -1,10 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"target": "ES6", "target": "ES6",
"skipLibCheck": true, "skipLibCheck": true,
@ -23,19 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }