Fixed date issues.
This commit is contained in:
parent
413cd17472
commit
7179cd3842
71
README.md
71
README.md
@ -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 |
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[] {
|
||||||
|
|||||||
@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user