Fixed date issues.
This commit is contained in:
parent
413cd17472
commit
7179cd3842
71
README.md
71
README.md
@ -1,3 +1,72 @@
|
||||
# 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
|
||||
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+
|
||||
// Bar size options per timeframe
|
||||
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'],
|
||||
'3M': ['1h', '4h', '1D', '1W'],
|
||||
'6M': ['4h', '1D', '1W'],
|
||||
'YTD': ['4h', '1D', '1W'],
|
||||
'1Y': ['1D', '1W'],
|
||||
'2Y': ['1D', '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.
|
||||
* 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+
|
||||
* Get bar size options for custom timeframe based on date range
|
||||
*/
|
||||
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' }
|
||||
return { options: ['5m', '15m', '30m', '1h'], default: '30m' }
|
||||
} 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 {
|
||||
return { options: ['1D', '1W'], default: '1D' }
|
||||
return { options: ['1W', '1M'], default: '1M' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Format date to YYYY-MM-DD for input
|
||||
*/
|
||||
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}`
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -196,7 +191,6 @@ function ComplexChartContent() {
|
||||
const start = calculateStartDate('5D', lastTradingDay)
|
||||
return formatDateForInput(start)
|
||||
})
|
||||
const [savedChart, setSavedChart] = useState('')
|
||||
|
||||
// Get available bar sizes based on current timeframe/dates
|
||||
const barSizeOptions = useMemo(() => {
|
||||
@ -255,14 +249,6 @@ function ComplexChartContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGo = () => {
|
||||
// Placeholder for Go button action
|
||||
}
|
||||
|
||||
const handleSaveChart = () => {
|
||||
// Placeholder for Save chart action
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
@ -310,31 +296,6 @@ function ComplexChartContent() {
|
||||
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>
|
||||
|
||||
|
||||
@ -153,10 +153,10 @@ function SymbolDetailsContent() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="p-4">
|
||||
{/* Chart will be embedded here */}
|
||||
<div className="p-4">
|
||||
<StockChart symbol={symbol} range={selectedRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<Link
|
||||
|
||||
@ -830,19 +830,20 @@ export function CandlestickGraph({
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Notes overlay */}
|
||||
{notes.map((note) => {
|
||||
// Find the candle index for this note's time
|
||||
const candleIndex = candles.findIndex(c => c.time === note.time);
|
||||
if (candleIndex === -1) return null;
|
||||
|
||||
// Check if note is in visible range
|
||||
if (candleIndex < viewModel.visibleStartIndex || candleIndex > viewModel.visibleEndIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x = getXForIndex(candleIndex);
|
||||
const y = getYForPrice(note.price);
|
||||
{/* Notes overlay */}
|
||||
{notes.map((note) => {
|
||||
// Check if note is within the visible time range
|
||||
if (candles.length === 0) return null;
|
||||
|
||||
const startCandle = candles[viewModel.visibleStartIndex];
|
||||
const endCandle = candles[viewModel.visibleEndIndex];
|
||||
|
||||
if (!startCandle || !endCandle) return null;
|
||||
if (note.time < startCandle.time || note.time > endCandle.time) return null;
|
||||
|
||||
// Use interpolated time positioning (works across all bar sizes)
|
||||
const x = getXForTime(note.time);
|
||||
const y = getYForPrice(note.price);
|
||||
|
||||
return (
|
||||
<NoteComponent
|
||||
|
||||
@ -54,94 +54,79 @@ function toDate(date: string | Date): Date {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two dates are the same calendar day (UTC).
|
||||
* Must use UTC methods because date strings like "2026-03-20" parse as UTC midnight.
|
||||
* Checks if two dates are the same calendar day.
|
||||
*/
|
||||
function isSameDay(date1: Date, date2: Date): boolean {
|
||||
return (
|
||||
date1.getUTCFullYear() === date2.getUTCFullYear() &&
|
||||
date1.getUTCMonth() === date2.getUTCMonth() &&
|
||||
date1.getUTCDate() === date2.getUTCDate()
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
let tradingDays = 0;
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
const dayOfWeek = current.getUTCDay();
|
||||
const dayOfWeek = current.getDay();
|
||||
// 0 = Sunday, 6 = Saturday
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
tradingDays++;
|
||||
}
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return tradingDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* IB API maximum duration in calendar days per bar size.
|
||||
* 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.
|
||||
* Converts trading days to API duration format.
|
||||
* 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:
|
||||
* ~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 {
|
||||
if (tradingDays <= 0) {
|
||||
return '1+D';
|
||||
}
|
||||
|
||||
// Convert trading days to calendar days and clamp to IB API limit
|
||||
let calendarDays = Math.ceil(tradingDays * 7 / 5);
|
||||
calendarDays = Math.min(calendarDays, getMaxCalendarDays(barSize));
|
||||
// Convert trading days to calendar days (7 calendar days per 5 trading days)
|
||||
const calendarDays = Math.ceil(tradingDays * 7 / 5);
|
||||
|
||||
// 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 (calendarDays <= 6) return `${calendarDays}+D`;
|
||||
if (calendarDays <= 28) {
|
||||
return `${Math.floor(calendarDays / 7)}+W`;
|
||||
}
|
||||
const weeks = Math.ceil(calendarDays / 7);
|
||||
if (weeks <= 4) return `${weeks}+W`;
|
||||
return '1+M';
|
||||
}
|
||||
|
||||
// For other bars, use D when possible
|
||||
if (calendarDays <= 365) {
|
||||
// For short durations, use days
|
||||
if (calendarDays <= 90) {
|
||||
return `${calendarDays}+D`;
|
||||
}
|
||||
|
||||
// Larger durations: use M or Y (floor to avoid overshooting the cap)
|
||||
const months = Math.floor(calendarDays / 30);
|
||||
if (months <= 12) return `${months}+M`;
|
||||
return `${Math.floor(months / 12)}+Y`;
|
||||
// For medium durations, use weeks
|
||||
const weeks = Math.ceil(calendarDays / 7);
|
||||
if (weeks <= 52) {
|
||||
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,
|
||||
@ -201,8 +186,12 @@ async function fetchCandlesFromAPI(
|
||||
endDateTime = endOfDay.toISOString();
|
||||
// Default to 5m bars for intraday if a daily/weekly bar size is specified
|
||||
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)
|
||||
duration = `${Math.min(3, getMaxCalendarDays(intradayBarSize))}+D`;
|
||||
// Cap warmup days to IB API max duration per bar size
|
||||
// (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';
|
||||
} else {
|
||||
const tradingDays = calculateTradingDays(start, end);
|
||||
@ -351,4 +340,4 @@ export function mergeCandles(existing: Candle[], incoming: Candle[]): Candle[] {
|
||||
|
||||
// Sort by time and return
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
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[]) {
|
||||
@ -46,18 +52,18 @@ function sortByTimestamp<T extends { timestamp: string }>(rows: T[]) {
|
||||
|
||||
function keepLatestTradingDay<T extends { timestamp: string }>(rows: T[]) {
|
||||
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
|
||||
|
||||
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) {
|
||||
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))
|
||||
return sorted.filter((r) => keep.has(getUtcDateKey(r.timestamp)))
|
||||
return sorted.filter((r) => keep.has(getTradingDayKey(r.timestamp)))
|
||||
}
|
||||
|
||||
function normalizeRawRows(input: unknown[]): RawHistoricalLinePoint[] {
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
@ -23,19 +19,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user