Show all UI times in GMT+7 and unify the timeframe list across the app
Every displayed timestamp previously relied on either the viewer's browser-local timezone (toLocaleString/toLocaleDateString/toLocaleTimeString) or raw UTC ISO-string slicing (.slice(0,10)/.slice(5,16)) — both wrong for a Vietnam-based system, and the string-slicing approach could show the wrong calendar date entirely near the UTC/GMT+7 day boundary. Added frontend/src/utils/dateTime.ts with formatVN* helpers that explicitly render in Asia/Ho_Chi_Minh regardless of the viewer's machine, and applied them across AdminPage, OrderPanel's live clock, SignalPanel, ProfilePage, AuditLogPage, and BacktestPage (including the new walk-forward fold/history dates) — 10 display sites total. Also consolidated the timeframe list (15m/30m/1h/4h/1d/1w/1M), which had drifted into 4 different copies across BacktestPage, ProfilePage, ChartToolbar, and AlertsPage, into a single frontend/src/utils/timeframes.ts source of truth. Extended Walk-Forward's timeframe options from 1h/4h to 1h/4h/1d, and fixed a latent backend bug where backtest_engine.py's tf_minutes map was missing "1d", silently defaulting to 30 minutes for any daily-timeframe backtest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
adminGetHealth,
|
||||
} from '../api/apiService';
|
||||
import type { User, Exchange } from '../../types/trading';
|
||||
import { formatVNDateOnly } from '../../utils/dateTime';
|
||||
|
||||
interface HealthData {
|
||||
status: string;
|
||||
@@ -472,7 +473,7 @@ export default function AdminPage() {
|
||||
{u.is_active ? <span className="text-xs font-semibold text-green">● Active</span> : <span className="text-xs font-semibold text-red">● Inactive</span>}
|
||||
</button>
|
||||
</td>
|
||||
<td className={`${tdClass} text-text-dim`}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
|
||||
<td className={`${tdClass} text-text-dim`}>{formatVNDateOnly(u.created_at)}</td>
|
||||
<td className={tdClass}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_admin')}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type AlertItem,
|
||||
type ConditionObject,
|
||||
} from '../api/alertApi';
|
||||
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
|
||||
|
||||
// ── Shared Tailwind class fragments ──
|
||||
const btnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
|
||||
@@ -41,11 +42,7 @@ const OPERATOR_OPTIONS = [
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: '', label: 'Any' },
|
||||
{ value: '15m', label: '15m' },
|
||||
{ value: '30m', label: '30m' },
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '4h', label: '4h' },
|
||||
{ value: '1d', label: '1d' },
|
||||
...ALL_TIMEFRAMES.map(tf => ({ value: tf, label: tf })),
|
||||
];
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useT } from '../../translations';
|
||||
import { apiFetch, ApiServiceError } from '../api/apiService';
|
||||
import { formatVNShort, formatVNDateShort } from '../../utils/dateTime';
|
||||
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
|
||||
|
||||
interface SignalCounts {
|
||||
[key: string]: number;
|
||||
@@ -117,8 +119,11 @@ const SIGNAL_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h'];
|
||||
const WF_TIMEFRAMES = ['1h', '4h'];
|
||||
const TIMEFRAMES = ALL_TIMEFRAMES;
|
||||
// Walk-forward is capped at 1h/4h/1d — over the ~3yr default lookback, 1w
|
||||
// gives only ~150 candles and 1M only ~36, too few to split into folds
|
||||
// with enough history left for indicator warmup (BB/RSI need 20+ candles).
|
||||
const WF_TIMEFRAMES = ['1h', '4h', '1d'];
|
||||
|
||||
const selectClass = 'rounded-md border border-border-default bg-bg-surface px-2.5 py-1.5 text-sm text-text-primary';
|
||||
const thClass = 'px-3 py-2 text-left font-medium text-text-secondary';
|
||||
@@ -320,7 +325,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Time</th>
|
||||
<th className={thClass}>Time (GMT+7)</th>
|
||||
<th className={thClass}>Signal</th>
|
||||
<th className={thClass}>Price</th>
|
||||
</tr>
|
||||
@@ -328,7 +333,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
|
||||
<tbody>
|
||||
{result.recent_signals.map((s, i) => (
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className={tdClass}>{s.time.slice(5, 16)}</td>
|
||||
<td className={tdClass}>{formatVNShort(s.time)}</td>
|
||||
<td className={tdClass}>
|
||||
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
|
||||
</td>
|
||||
@@ -348,7 +353,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Time</th>
|
||||
<th className={thClass}>Time (GMT+7)</th>
|
||||
<th className={thClass}>Dir</th>
|
||||
<th className={thClass}>Entry</th>
|
||||
<th className={thClass}>Exit</th>
|
||||
@@ -359,7 +364,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
|
||||
<tbody>
|
||||
{result.trades.recent.slice().reverse().map((t, i) => (
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className={tdClass}>{t.entry_time?.slice(5, 16) || '-'}</td>
|
||||
<td className={tdClass}>{formatVNShort(t.entry_time)}</td>
|
||||
<td className={tdClass}>{t.direction}</td>
|
||||
<td className={tdClass}>${t.entry_price.toFixed(2)}</td>
|
||||
<td className={tdClass}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
@@ -566,8 +571,8 @@ function WalkForwardView({ exchange, setExchange, symbol, setSymbol, symbols, tr
|
||||
<thead>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Fold</th>
|
||||
<th className={thClass}>Train</th>
|
||||
<th className={thClass}>Test</th>
|
||||
<th className={thClass}>Train (GMT+7)</th>
|
||||
<th className={thClass}>Test (GMT+7)</th>
|
||||
<th className={thClass}>Best Params</th>
|
||||
<th className={thClass}>In-Sample WR / PnL</th>
|
||||
<th className={thClass}>Out-of-Sample WR / PnL</th>
|
||||
@@ -577,8 +582,8 @@ function WalkForwardView({ exchange, setExchange, symbol, setSymbol, symbols, tr
|
||||
{result.folds.map(f => (
|
||||
<tr key={f.fold_index} className="border-b border-border-muted">
|
||||
<td className={tdClass}>#{f.fold_index + 1}</td>
|
||||
<td className={tdClass}>{f.train_start.slice(0, 10)} → {f.train_end.slice(0, 10)}</td>
|
||||
<td className={tdClass}>{f.test_start.slice(0, 10)} → {f.test_end.slice(0, 10)}</td>
|
||||
<td className={tdClass}>{formatVNDateShort(f.train_start)} → {formatVNDateShort(f.train_end)}</td>
|
||||
<td className={tdClass}>{formatVNDateShort(f.test_start)} → {formatVNDateShort(f.test_end)}</td>
|
||||
<td className={`${tdClass} text-text-dim`}>
|
||||
S≥{f.best_params.strong_threshold} / B≥{f.best_params.signal_threshold} / {f.best_params.max_hold_candles}c
|
||||
</td>
|
||||
@@ -611,7 +616,7 @@ function WalkForwardView({ exchange, setExchange, symbol, setSymbol, symbols, tr
|
||||
<th className={thClass}>Folds</th>
|
||||
<th className={thClass}>OOS Win Rate</th>
|
||||
<th className={thClass}>OOS PnL</th>
|
||||
<th className={thClass}>Ngày</th>
|
||||
<th className={thClass}>Ngày (GMT+7)</th>
|
||||
<th className={thClass}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -623,7 +628,7 @@ function WalkForwardView({ exchange, setExchange, symbol, setSymbol, symbols, tr
|
||||
<td className={tdClass}>{h.folds_count}</td>
|
||||
<td className={tdClass}>{h.oos_win_rate ?? '-'}%</td>
|
||||
<td className={`${tdClass} ${(h.oos_total_pnl ?? 0) >= 0 ? 'text-green' : 'text-red'}`}>${(h.oos_total_pnl ?? 0).toFixed(2)}</td>
|
||||
<td className={`${tdClass} text-text-dim`}>{h.created_at.slice(0, 10)}</td>
|
||||
<td className={`${tdClass} text-text-dim`}>{formatVNDateShort(h.created_at)}</td>
|
||||
<td className={tdClass}>
|
||||
<button onClick={() => loadPastRun(h.id)} className="mr-2 text-accent underline">Xem</button>
|
||||
<button onClick={() => deletePastRun(h.id)} className="text-red underline">Xóa</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
|
||||
|
||||
interface Exchange {
|
||||
id: number;
|
||||
@@ -26,7 +27,7 @@ interface Props {
|
||||
onTimeframeChange: (timeframe: string) => void;
|
||||
}
|
||||
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
||||
const TIMEFRAMES = ALL_TIMEFRAMES;
|
||||
|
||||
const selectClass = 'h-6 w-full min-w-0 max-w-none rounded-md border border-border-default bg-bg-primary px-2 text-[11px] leading-4 text-text-primary outline-none cursor-pointer md:w-auto md:min-w-[100px] md:max-w-[140px]';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { useAppSelector } from '../../app/hooks';
|
||||
import { formatVNTimeOnly, formatVNDateOnly } from '../../utils/dateTime';
|
||||
|
||||
interface Props {
|
||||
symbol: string;
|
||||
@@ -132,10 +133,10 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
||||
{/* Time + Balance row (50/50) - stacks on mobile */}
|
||||
<div className="flex flex-col items-stretch overflow-hidden rounded-lg border border-border-default bg-bg-surface md:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Time</h3>
|
||||
<div className="mb-1 font-mono text-sm text-text-primary md:text-[15px]">{currentTime.toLocaleTimeString()}</div>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Time (GMT+7)</h3>
|
||||
<div className="mb-1 font-mono text-sm text-text-primary md:text-[15px]">{formatVNTimeOnly(currentTime)}</div>
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
{formatVNDateOnly(currentTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-0 my-1.5 h-px w-auto shrink-0 bg-border-default md:mx-0 md:my-0 md:h-auto md:w-px" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { fetchSignals, fetchTrades, SIGNAL_CONFIG, formatPrice, formatPnL, formatPnLPercent } from '../api/signalApi';
|
||||
import type { Signal, Trade } from '../api/signalApi';
|
||||
import { formatVNTimeOnly } from '../../utils/dateTime';
|
||||
interface Props {
|
||||
symbol: string;
|
||||
}
|
||||
@@ -111,7 +112,7 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-[13px] font-medium text-text-primary">${formatPrice(sig.price)}</div>
|
||||
<div className="text-[11px] text-text-dim">
|
||||
{new Date(sig.created_at).toLocaleTimeString()}
|
||||
{formatVNTimeOnly(sig.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { logoutThunk } from '../auth/authSlice';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { useT } from '../../translations';
|
||||
import { formatVNDateOnly, formatVNShort } from '../../utils/dateTime';
|
||||
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
|
||||
|
||||
// ── Shared ──
|
||||
// Thin wrapper that strips the /api/v1 prefix and delegates to the canonical apiFetch
|
||||
@@ -19,9 +21,9 @@ const labelClass = 'mb-1 text-[11px] text-text-secondary';
|
||||
const cardClass = 'rounded-lg border border-border-muted bg-bg-surface p-3.5';
|
||||
|
||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||
const TFS_BT = ['15m', '30m', '1h', '4h'];
|
||||
const TFS_BT = ALL_TIMEFRAMES;
|
||||
const DAYS = [3, 7, 14, 30, 90, 180, 365];
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
||||
const TIMEFRAMES = ALL_TIMEFRAMES;
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ code: 'vi', label: '🇻🇳 Tiếng Việt' },
|
||||
{ code: 'en', label: '🇺🇸 English' },
|
||||
@@ -59,7 +61,7 @@ function formatTimeAgo(dateStr: string): string {
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
return formatVNDateOnly(dateStr);
|
||||
}
|
||||
|
||||
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
@@ -116,7 +118,7 @@ function InfoTab() {
|
||||
<div><div className={labelClass}>Display Name</div><div className="text-text-primary">{profile.display_name || '-'}</div></div>
|
||||
<div><div className={labelClass}>Email</div><div className="text-text-primary">{profile.email}</div></div>
|
||||
<div><div className={labelClass}>Role</div><div className="text-text-primary">{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
|
||||
<div><div className={labelClass}>Member since</div><div className="text-text-primary">{profile.created_at?.slice(0, 10)}</div></div>
|
||||
<div><div className={labelClass}>Member since</div><div className="text-text-primary">{formatVNDateOnly(profile.created_at)}</div></div>
|
||||
<div className="flex gap-2"><button onClick={() => setEditing(true)} className={btnClass}>✏️ Edit</button><button onClick={() => setPwModal(true)} className={`${btnClass} border-yellow text-yellow`}>🔑 Change Password</button></div>
|
||||
</>)}
|
||||
</div>
|
||||
@@ -733,7 +735,7 @@ function BacktestTab() {
|
||||
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Time</th><th className="p-1 text-left">Dir</th><th className="p-1 text-left">Entry</th><th className="p-1 text-left">Exit</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Reason</th></tr></thead>
|
||||
<tbody>{btResult.trades.recent.slice(-10).reverse().map((t: any, i: number) => (
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className="p-1">{t.entry_time?.slice(5, 16)}</td>
|
||||
<td className="p-1">{formatVNShort(t.entry_time)}</td>
|
||||
<td className={`p-1 ${t.direction === 'LONG' ? 'text-green-alt' : 'text-red-alt'}`}>{t.direction}</td>
|
||||
<td className="p-1">${t.entry_price?.toFixed(2)}</td>
|
||||
<td className="p-1">${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../app/hooks';
|
||||
import { fetchCurrentUser, logoutThunk } from '../features/auth/authSlice';
|
||||
import { apiFetch } from '../features/api/apiService';
|
||||
import { formatVNDateTime } from '../utils/dateTime';
|
||||
|
||||
// ── Types ──
|
||||
interface AuditLogEntry {
|
||||
@@ -121,10 +122,7 @@ export default function AuditLogPage() {
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
const formatTime = (iso: string) => formatVNDateTime(iso);
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
trade_open: '#3fb950',
|
||||
@@ -219,7 +217,7 @@ export default function AuditLogPage() {
|
||||
<table className="w-full min-w-[600px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Time</th>
|
||||
<th className={thClass}>Time (GMT+7)</th>
|
||||
<th className={thClass}>User</th>
|
||||
<th className={thClass}>Action</th>
|
||||
<th className={thClass}>Resource</th>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// All timestamps from the backend are UTC. The system's users are in
|
||||
// Vietnam, so every date/time shown in the UI must render in GMT+7
|
||||
// regardless of the viewer's own machine/browser timezone — hence the
|
||||
// explicit `timeZone` on every formatter here rather than relying on
|
||||
// `toLocaleString()`'s browser-local default.
|
||||
const VN_TIMEZONE = 'Asia/Ho_Chi_Minh';
|
||||
|
||||
function toDate(value: string | number | Date | null | undefined): Date | null {
|
||||
if (value == null) return null;
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function partsOf(d: Date, opts: Intl.DateTimeFormatOptions): Record<string, string> {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: VN_TIMEZONE, ...opts }).formatToParts(d);
|
||||
const out: Record<string, string> = {};
|
||||
for (const p of parts) out[p.type] = p.value;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Full date + time, e.g. "04 Jul 2026, 14:30" (GMT+7). */
|
||||
export function formatVNDateTime(value: string | number | Date | null | undefined): string {
|
||||
const d = toDate(value);
|
||||
if (!d) return '-';
|
||||
return d.toLocaleString('en-GB', { timeZone: VN_TIMEZONE, day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Date only, e.g. "04/07/2026" (GMT+7). */
|
||||
export function formatVNDateOnly(value: string | number | Date | null | undefined): string {
|
||||
const d = toDate(value);
|
||||
if (!d) return '-';
|
||||
return d.toLocaleDateString('en-GB', { timeZone: VN_TIMEZONE, day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Time only, e.g. "14:30" (GMT+7). */
|
||||
export function formatVNTimeOnly(value: string | number | Date | null | undefined): string {
|
||||
const d = toDate(value);
|
||||
if (!d) return '-';
|
||||
return d.toLocaleTimeString('en-GB', { timeZone: VN_TIMEZONE, hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Compact "MM-DD HH:mm" (GMT+7) — used in dense signal/trade tables. */
|
||||
export function formatVNShort(value: string | number | Date | null | undefined): string {
|
||||
const d = toDate(value);
|
||||
if (!d) return '-';
|
||||
const p = partsOf(d, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
return `${p.month}-${p.day} ${p.hour}:${p.minute}`;
|
||||
}
|
||||
|
||||
/** Compact "YYYY-MM-DD" (GMT+7) — used for date-only ranges (e.g. walk-forward fold windows). */
|
||||
export function formatVNDateShort(value: string | number | Date | null | undefined): string {
|
||||
const d = toDate(value);
|
||||
if (!d) return '-';
|
||||
const p = partsOf(d, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
return `${p.year}-${p.month}-${p.day}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Single source of truth for the timeframes the system supports —
|
||||
// was previously copy-pasted (and drifted) across BacktestPage, ProfilePage,
|
||||
// ChartToolbar, and AlertsPage.
|
||||
export const ALL_TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
|
||||
export type Timeframe = (typeof ALL_TIMEFRAMES)[number];
|
||||
Reference in New Issue
Block a user