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:
Le
2026-07-04 09:31:48 +07:00
parent 625c2b3773
commit 56325113b3
13 changed files with 113 additions and 39 deletions
+2 -1
View File
@@ -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')}>
+2 -5
View File
@@ -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 = [
+17 -12
View File
@@ -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 -5
View File
@@ -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>
+56
View File
@@ -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}`;
}
+5
View File
@@ -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];