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
+1 -1
View File
@@ -70,7 +70,7 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
(see `_simulate_trades`). (see `_simulate_trades`).
""" """
# MTF config # MTF config
tf_minutes = {"15m": 15, "30m": 30, "1h": 60, "4h": 240} tf_minutes = {"15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440, "1w": 10080, "1M": 43200}
main_minutes = tf_minutes.get(timeframe, 30) main_minutes = tf_minutes.get(timeframe, 30)
mtf_config = [] mtf_config = []
for mtf_tf, mtf_minutes, mtf_w in [("15m", 15, 0.5), ("1h", 60, 1.5), ("4h", 240, 2.0)]: for mtf_tf, mtf_minutes, mtf_w in [("15m", 15, 0.5), ("1h", 60, 1.5), ("4h", 240, 2.0)]:
+1 -1
View File
@@ -45,7 +45,7 @@ DEFAULT_PARAM_GRID: dict[str, list[float]] = {
MIN_TRADES_PER_FOLD = 5 # reject param combos too sparse to trust MIN_TRADES_PER_FOLD = 5 # reject param combos too sparse to trust
WARMUP_BUFFER_CANDLES = 60 # extra history fetched before each window so indicators aren't cold at window start WARMUP_BUFFER_CANDLES = 60 # extra history fetched before each window so indicators aren't cold at window start
_TF_MINUTES = {"15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440} _TF_MINUTES = {"15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440, "1w": 10080, "1M": 43200}
def _fold_score(closed_trades: list[dict], min_trades: int = MIN_TRADES_PER_FOLD) -> float: def _fold_score(closed_trades: list[dict], min_trades: int = MIN_TRADES_PER_FOLD) -> float:
+2 -1
View File
@@ -14,6 +14,7 @@ import {
adminGetHealth, adminGetHealth,
} from '../api/apiService'; } from '../api/apiService';
import type { User, Exchange } from '../../types/trading'; import type { User, Exchange } from '../../types/trading';
import { formatVNDateOnly } from '../../utils/dateTime';
interface HealthData { interface HealthData {
status: string; 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>} {u.is_active ? <span className="text-xs font-semibold text-green">● Active</span> : <span className="text-xs font-semibold text-red">● Inactive</span>}
</button> </button>
</td> </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}> <td className={tdClass}>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_admin')}> <button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_admin')}>
+2 -5
View File
@@ -10,6 +10,7 @@ import {
type AlertItem, type AlertItem,
type ConditionObject, type ConditionObject,
} from '../api/alertApi'; } from '../api/alertApi';
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
// ── Shared Tailwind class fragments ── // ── 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'; 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 = [ const TIMEFRAME_OPTIONS = [
{ value: '', label: 'Any' }, { value: '', label: 'Any' },
{ value: '15m', label: '15m' }, ...ALL_TIMEFRAMES.map(tf => ({ value: tf, label: tf })),
{ value: '30m', label: '30m' },
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
{ value: '1d', label: '1d' },
]; ];
const PLATFORM_OPTIONS = [ const PLATFORM_OPTIONS = [
+17 -12
View File
@@ -1,6 +1,8 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useT } from '../../translations'; import { useT } from '../../translations';
import { apiFetch, ApiServiceError } from '../api/apiService'; import { apiFetch, ApiServiceError } from '../api/apiService';
import { formatVNShort, formatVNDateShort } from '../../utils/dateTime';
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
interface SignalCounts { interface SignalCounts {
[key: string]: number; [key: string]: number;
@@ -117,8 +119,11 @@ const SIGNAL_ICONS: Record<string, string> = {
}; };
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx']; const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
const TIMEFRAMES = ['15m', '30m', '1h', '4h']; const TIMEFRAMES = ALL_TIMEFRAMES;
const WF_TIMEFRAMES = ['1h', '4h']; // 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 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'; 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]"> <table className="w-full border-collapse text-[13px]">
<thead> <thead>
<tr className="border-b border-border-default"> <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}>Signal</th>
<th className={thClass}>Price</th> <th className={thClass}>Price</th>
</tr> </tr>
@@ -328,7 +333,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
<tbody> <tbody>
{result.recent_signals.map((s, i) => ( {result.recent_signals.map((s, i) => (
<tr key={i} className="border-b border-border-muted"> <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}> <td className={tdClass}>
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')} {SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
</td> </td>
@@ -348,7 +353,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
<table className="w-full border-collapse text-[13px]"> <table className="w-full border-collapse text-[13px]">
<thead> <thead>
<tr className="border-b border-border-default"> <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}>Dir</th>
<th className={thClass}>Entry</th> <th className={thClass}>Entry</th>
<th className={thClass}>Exit</th> <th className={thClass}>Exit</th>
@@ -359,7 +364,7 @@ function SingleRunView({ exchange, setExchange, symbol, setSymbol, symbols, trad
<tbody> <tbody>
{result.trades.recent.slice().reverse().map((t, i) => ( {result.trades.recent.slice().reverse().map((t, i) => (
<tr key={i} className="border-b border-border-muted"> <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.direction}</td>
<td className={tdClass}>${t.entry_price.toFixed(2)}</td> <td className={tdClass}>${t.entry_price.toFixed(2)}</td>
<td className={tdClass}>${t.exit_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> <thead>
<tr className="border-b border-border-default"> <tr className="border-b border-border-default">
<th className={thClass}>Fold</th> <th className={thClass}>Fold</th>
<th className={thClass}>Train</th> <th className={thClass}>Train (GMT+7)</th>
<th className={thClass}>Test</th> <th className={thClass}>Test (GMT+7)</th>
<th className={thClass}>Best Params</th> <th className={thClass}>Best Params</th>
<th className={thClass}>In-Sample WR / PnL</th> <th className={thClass}>In-Sample WR / PnL</th>
<th className={thClass}>Out-of-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 => ( {result.folds.map(f => (
<tr key={f.fold_index} className="border-b border-border-muted"> <tr key={f.fold_index} className="border-b border-border-muted">
<td className={tdClass}>#{f.fold_index + 1}</td> <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}>{formatVNDateShort(f.train_start)} → {formatVNDateShort(f.train_end)}</td>
<td className={tdClass}>{f.test_start.slice(0, 10)} → {f.test_end.slice(0, 10)}</td> <td className={tdClass}>{formatVNDateShort(f.test_start)} → {formatVNDateShort(f.test_end)}</td>
<td className={`${tdClass} text-text-dim`}> <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 S≥{f.best_params.strong_threshold} / B≥{f.best_params.signal_threshold} / {f.best_params.max_hold_candles}c
</td> </td>
@@ -611,7 +616,7 @@ function WalkForwardView({ exchange, setExchange, symbol, setSymbol, symbols, tr
<th className={thClass}>Folds</th> <th className={thClass}>Folds</th>
<th className={thClass}>OOS Win Rate</th> <th className={thClass}>OOS Win Rate</th>
<th className={thClass}>OOS PnL</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> <th className={thClass}></th>
</tr> </tr>
</thead> </thead>
@@ -623,7 +628,7 @@ function WalkForwardView({ exchange, setExchange, symbol, setSymbol, symbols, tr
<td className={tdClass}>{h.folds_count}</td> <td className={tdClass}>{h.folds_count}</td>
<td className={tdClass}>{h.oos_win_rate ?? '-'}%</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} ${(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}> <td className={tdClass}>
<button onClick={() => loadPastRun(h.id)} className="mr-2 text-accent underline">Xem</button> <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> <button onClick={() => deletePastRun(h.id)} className="text-red underline">Xóa</button>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { apiFetch } from '../api/apiService'; import { apiFetch } from '../api/apiService';
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
interface Exchange { interface Exchange {
id: number; id: number;
@@ -26,7 +27,7 @@ interface Props {
onTimeframeChange: (timeframe: string) => void; 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]'; 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 { useEffect, useRef, useState } from 'react';
import { apiFetch } from '../api/apiService'; import { apiFetch } from '../api/apiService';
import { useAppSelector } from '../../app/hooks'; import { useAppSelector } from '../../app/hooks';
import { formatVNTimeOnly, formatVNDateOnly } from '../../utils/dateTime';
interface Props { interface Props {
symbol: string; symbol: string;
@@ -132,10 +133,10 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
{/* Time + Balance row (50/50) - stacks on mobile */} {/* 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-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"> <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> <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]">{currentTime.toLocaleTimeString()}</div> <div className="mb-1 font-mono text-sm text-text-primary md:text-[15px]">{formatVNTimeOnly(currentTime)}</div>
<div className="text-[11px] text-text-secondary"> <div className="text-[11px] text-text-secondary">
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} {formatVNDateOnly(currentTime)}
</div> </div>
</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" /> <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 { useEffect, useState, useCallback } from 'react';
import { fetchSignals, fetchTrades, SIGNAL_CONFIG, formatPrice, formatPnL, formatPnLPercent } from '../api/signalApi'; import { fetchSignals, fetchTrades, SIGNAL_CONFIG, formatPrice, formatPnL, formatPnLPercent } from '../api/signalApi';
import type { Signal, Trade } from '../api/signalApi'; import type { Signal, Trade } from '../api/signalApi';
import { formatVNTimeOnly } from '../../utils/dateTime';
interface Props { interface Props {
symbol: string; symbol: string;
} }
@@ -111,7 +112,7 @@ export default function SignalPanel({ symbol }: Props) {
<div className="text-right"> <div className="text-right">
<div className="font-mono text-[13px] font-medium text-text-primary">${formatPrice(sig.price)}</div> <div className="font-mono text-[13px] font-medium text-text-primary">${formatPrice(sig.price)}</div>
<div className="text-[11px] text-text-dim"> <div className="text-[11px] text-text-dim">
{new Date(sig.created_at).toLocaleTimeString()} {formatVNTimeOnly(sig.created_at)}
</div> </div>
</div> </div>
</div> </div>
@@ -4,6 +4,8 @@ import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { logoutThunk } from '../auth/authSlice'; import { logoutThunk } from '../auth/authSlice';
import { apiFetch } from '../api/apiService'; import { apiFetch } from '../api/apiService';
import { useT } from '../../translations'; import { useT } from '../../translations';
import { formatVNDateOnly, formatVNShort } from '../../utils/dateTime';
import { ALL_TIMEFRAMES } from '../../utils/timeframes';
// ── Shared ── // ── Shared ──
// Thin wrapper that strips the /api/v1 prefix and delegates to the canonical apiFetch // 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 cardClass = 'rounded-lg border border-border-muted bg-bg-surface p-3.5';
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx']; 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 DAYS = [3, 7, 14, 30, 90, 180, 365];
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M']; const TIMEFRAMES = ALL_TIMEFRAMES;
const LANGUAGE_OPTIONS = [ const LANGUAGE_OPTIONS = [
{ code: 'vi', label: '🇻🇳 Tiếng Việt' }, { code: 'vi', label: '🇻🇳 Tiếng Việt' },
{ code: 'en', label: '🇺🇸 English' }, { code: 'en', label: '🇺🇸 English' },
@@ -59,7 +61,7 @@ function formatTimeAgo(dateStr: string): string {
if (hrs < 24) return `${hrs}h ago`; if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24); const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`; 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 }) { 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}>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}>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}>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 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> </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> <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) => ( <tbody>{btResult.trades.recent.slice(-10).reverse().map((t: any, i: number) => (
<tr key={i} className="border-b border-border-muted"> <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.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.entry_price?.toFixed(2)}</td>
<td className="p-1">${t.exit_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 { useAppSelector, useAppDispatch } from '../app/hooks';
import { fetchCurrentUser, logoutThunk } from '../features/auth/authSlice'; import { fetchCurrentUser, logoutThunk } from '../features/auth/authSlice';
import { apiFetch } from '../features/api/apiService'; import { apiFetch } from '../features/api/apiService';
import { formatVNDateTime } from '../utils/dateTime';
// ── Types ── // ── Types ──
interface AuditLogEntry { interface AuditLogEntry {
@@ -121,10 +122,7 @@ export default function AuditLogPage() {
}; };
// ── Helpers ── // ── Helpers ──
const formatTime = (iso: string) => { const formatTime = (iso: string) => formatVNDateTime(iso);
const d = new Date(iso);
return d.toLocaleString();
};
const actionColors: Record<string, string> = { const actionColors: Record<string, string> = {
trade_open: '#3fb950', trade_open: '#3fb950',
@@ -219,7 +217,7 @@ export default function AuditLogPage() {
<table className="w-full min-w-[600px] border-collapse text-xs"> <table className="w-full min-w-[600px] border-collapse text-xs">
<thead> <thead>
<tr className="border-b border-border-default"> <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}>User</th>
<th className={thClass}>Action</th> <th className={thClass}>Action</th>
<th className={thClass}>Resource</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];
+11 -4
View File
@@ -49,13 +49,20 @@ Hệ thống dùng backtest trên dữ liệu lịch sử để đánh giá chi
- `backend/app/api/v1/walk_forward.py` (MỚI) — `POST /walk-forward/run` (tự lưu), `GET /walk-forward/history`, `GET /walk-forward/{id}`, `DELETE /walk-forward/{id}`. Yêu cầu đăng nhập. - `backend/app/api/v1/walk_forward.py` (MỚI) — `POST /walk-forward/run` (tự lưu), `GET /walk-forward/history`, `GET /walk-forward/{id}`, `DELETE /walk-forward/{id}`. Yêu cầu đăng nhập.
- `backend/alembic/versions/merge_heads_1.py` (MỚI) — **phát hiện phụ**: lịch sử Alembic đã bị phân nhánh thành 2 head không hợp nhất (`1b3f1630986f` và `4_add_sl_tp_columns`, cả hai đều rẽ từ `add_candle_partitions`) từ trước, khiến `alembic upgrade head` sẽ lỗi mơ hồ trên môi trường mới. Đã tạo migration merge (no-op) để gộp lại thành 1 head duy nhất trước khi thêm bảng mới. - `backend/alembic/versions/merge_heads_1.py` (MỚI) — **phát hiện phụ**: lịch sử Alembic đã bị phân nhánh thành 2 head không hợp nhất (`1b3f1630986f` và `4_add_sl_tp_columns`, cả hai đều rẽ từ `add_candle_partitions`) từ trước, khiến `alembic upgrade head` sẽ lỗi mơ hồ trên môi trường mới. Đã tạo migration merge (no-op) để gộp lại thành 1 head duy nhất trước khi thêm bảng mới.
- `backend/alembic/versions/add_walk_forward_results.py` (MỚI) — bảng `walk_forward_results` lưu lịch sử các lần chạy walk-forward. - `backend/alembic/versions/add_walk_forward_results.py` (MỚI) — bảng `walk_forward_results` lưu lịch sử các lần chạy walk-forward.
- `frontend/src/features/backtest/BacktestPage.tsx` — thêm toggle "Single Run / Walk-Forward"; tab Walk-Forward có control (Exchange/Symbol/Timeframe giới hạn 1h-4h để giữ thời gian chạy nhanh/Trade Size), tùy chọn nâng cao (window size), bảng chi tiết từng fold, equity curve SVG, và lịch sử các lần chạy. - `frontend/src/features/backtest/BacktestPage.tsx` — thêm toggle "Single Run / Walk-Forward"; tab Walk-Forward có control (Exchange/Symbol/Timeframe giới hạn 1h/4h/1d để giữ thời gian chạy nhanh/Trade Size), tùy chọn nâng cao (window size), bảng chi tiết từng fold, equity curve SVG, và lịch sử các lần chạy.
- **Test mới:** `test_backtest_engine.py` (8 test) + `test_walk_forward.py` (11 test) — tổng **153 test pass** (tăng từ 134). - **Test mới:** `test_backtest_engine.py` (8 test) + `test_walk_forward.py` (11 test) — tổng **153 test pass** (tăng từ 134).
### 2.6 Giới hạn đã biết (cố ý, có ghi chú trong code) ### 2.6 Bổ sung sau phản hồi người dùng: đầy đủ timeframe + giờ hiển thị GMT+7
Sau khi review, người dùng lưu ý 2 điểm:
1. **Timeframe hệ thống hỗ trợ đầy đủ là 15m/30m/1h/4h/1d/1w/1M** — nhưng danh sách timeframe bị khai báo rời rạc, không đồng bộ ở nhiều nơi (`BacktestPage.tsx` và `ProfilePage.tsx` chỉ có 4 mục, `ChartToolbar.tsx` có đủ 7 mục, `AlertsPage.tsx` có 6 mục thiếu 1w/1M) — 4 bản sao khác nhau, dễ lệch nhau. Đã tạo `frontend/src/utils/timeframes.ts` xuất `ALL_TIMEFRAMES` làm nguồn duy nhất, dùng lại ở cả 4 nơi. Backtest đơn (Single Run) và Profile giờ hỗ trợ đủ 7 timeframe; Walk-Forward mở rộng từ chỉ `['1h','4h']` thành `['1h','4h','1d']` (không thêm 1w/1M vì với lookback 3 năm mặc định, 1w chỉ cho ~150 nến và 1M chỉ ~36 nến — không đủ để chia fold train/test có ý nghĩa). Nhân tiện cũng phát hiện và sửa bug tiềm ẩn ở backend: `tf_minutes` dict trong `backtest_engine.py` thiếu "1d" nên nếu backtest chạy timeframe 1d sẽ âm thầm tính sai `main_minutes` (mặc định về 30) — đã bổ sung đủ 15m/30m/1h/4h/1d/1w/1M.
2. **Mọi giờ hiển thị trên giao diện phải là giờ Việt Nam (GMT+7)**, không phải UTC thô hay giờ local trình duyệt. Toàn bộ app trước đó dùng 3 cách khác nhau, đều sai theo yêu cầu này: `new Date(iso).toLocaleString()`/`.toLocaleDateString()`/`.toLocaleTimeString()` (phụ thuộc timezone của máy người xem, không cố định GMT+7), và `.slice(0,10)`/`.slice(5,16)` cắt thẳng chuỗi ISO UTC (hiển thị giờ UTC, sai lệch lên tới 7 tiếng — ví dụ 20:00 UTC ngày 4/7 thực ra là 03:00 ngày 5/7 giờ VN, cắt chuỗi sẽ hiển thị nhầm ngày 4/7). Đã tạo `frontend/src/utils/dateTime.ts` với các hàm `formatVNDateTime`/`formatVNDateOnly`/`formatVNTimeOnly`/`formatVNShort`/`formatVNDateShort` — dùng `Intl.DateTimeFormat` với `timeZone: 'Asia/Ho_Chi_Minh'` tường minh (không phụ thuộc timezone máy người xem), áp dụng cho toàn bộ 10 vị trí hiển thị giờ/ngày đã rà soát được: `AdminPage` (ngày tạo user), `OrderPanel` (đồng hồ trực tiếp trên Dashboard, đã gắn nhãn "(GMT+7)"), `SignalPanel` (giờ tín hiệu), `ProfilePage` (ngày tham gia, session hoạt động gần nhất, giờ trade trong backtest tab), `AuditLogPage` (giờ audit log, đã đổi tiêu đề cột thành "Time (GMT+7)"), `BacktestPage` (giờ tín hiệu/trade, khoảng ngày train/test của walk-forward, ngày lưu lịch sử — các tiêu đề cột liên quan đều gắn "(GMT+7)" cho rõ ràng).
### 2.7 Giới hạn đã biết (cố ý, có ghi chú trong code)
- Chỉ tối ưu 3 tham số ngưỡng/thời gian giữ lệnh, không tối ưu các hằng số nội bộ của 13 thuật toán — xem mục 2.2. - Chỉ tối ưu 3 tham số ngưỡng/thời gian giữ lệnh, không tối ưu các hằng số nội bộ của 13 thuật toán — xem mục 2.2.
- Timeframe walk-forward giới hạn 1h/4h trên UI (không cho 15m/30m) để giữ runtime nhanh — thuật toán backtest gốc dùng slicing mảng O(n²) mỗi candle, ở 4h/1h trên 3 năm vẫn đủ nhanh cho 1 request đồng bộ, nhưng ở 15m/30m số nến tăng gấp 4-8 lần sẽ chậm đáng kể. Không sửa thuật toán slicing gốc (out of scope, rủi ro cao hơn lợi ích ở đây). - Timeframe walk-forward giới hạn 1h/4h/1d trên UI (không cho 15m/30m/1w/1M) — 15m/30m để giữ runtime nhanh (thuật toán backtest gốc dùng slicing mảng O(n²) mỗi candle, ở 4h/1h/1d trên 3 năm vẫn đủ nhanh cho 1 request đồng bộ, nhưng ở 15m/30m số nến tăng gấp 4-16 lần sẽ chậm đáng kể); 1w/1M vì lookback 3 năm chỉ cho ~150/~36 nến, không đủ chia fold có ý nghĩa (xem mục 2.6). Không sửa thuật toán slicing gốc (out of scope, rủi ro cao hơn lợi ích ở đây).
- Chưa build async job/polling — `/walk-forward/run` chạy đồng bộ. Với default 3 năm/4h/9 fold thì đủ nhanh; nếu người dùng chỉnh nâng cao để chạy timeframe 1h với total_days rất lớn có thể chậm hơn — chưa có giới hạn cứng, chỉ giới hạn `total_days` trong khoảng [180, 1825] qua validation. - Chưa build async job/polling — `/walk-forward/run` chạy đồng bộ. Với default 3 năm/4h/9 fold thì đủ nhanh; nếu người dùng chỉnh nâng cao để chạy timeframe 1h với total_days rất lớn có thể chậm hơn — chưa có giới hạn cứng, chỉ giới hạn `total_days` trong khoảng [180, 1825] qua validation.
--- ---
@@ -117,4 +124,4 @@ Hệ thống dùng backtest trên dữ liệu lịch sử để đánh giá chi
| v4 | 2026-07-03 | Rewrite lịch sử git (a) chuẩn bị xong, fix (p), test indicator_service + async signal_service (122 test), phát hiện (q)/(r), ghi nhận sự cố quy trình (s) | | v4 | 2026-07-03 | Rewrite lịch sử git (a) chuẩn bị xong, fix (p), test indicator_service + async signal_service (122 test), phát hiện (q)/(r), ghi nhận sự cố quy trình (s) |
| v5 | 2026-07-03 | Đóng hoàn toàn sự cố (a) — rotate xong 4/4 mật khẩu, force-push lịch sử đã rewrite thành công, verify sạch | | v5 | 2026-07-03 | Đóng hoàn toàn sự cố (a) — rotate xong 4/4 mật khẩu, force-push lịch sử đã rewrite thành công, verify sạch |
| v6 | 2026-07-04 | Xử lý (h), (i), (j), (k), (l), (n), (o), (q), (r) — 134 test pass; Tailwind design system 14 file; thêm Redis; review UI, phát hiện & sửa (t, u, v), ghi nhận (w, x) | | v6 | 2026-07-04 | Xử lý (h), (i), (j), (k), (l), (n), (o), (q), (r) — 134 test pass; Tailwind design system 14 file; thêm Redis; review UI, phát hiện & sửa (t, u, v), ghi nhận (w, x) |
| v7 | 2026-07-04 | **(m) Walk-Forward Backtest Optimization** — grid search tự động 3 tham số, out-of-sample stitching, UI tab mới trong `/backtest`; tách `backtest_engine.py` khỏi API layer (tiếp nối tinh thần (h)); phát hiện & sửa Alembic 2-head phân nhánh (y); 153 test pass. **Toàn bộ danh sách nhược điểm gốc a→r đã đóng.** | | v7 | 2026-07-04 | **(m) Walk-Forward Backtest Optimization** — grid search tự động 3 tham số, out-of-sample stitching, UI tab mới trong `/backtest`; tách `backtest_engine.py` khỏi API layer (tiếp nối tinh thần (h)); phát hiện & sửa Alembic 2-head phân nhánh (y); 153 test pass. **Toàn bộ danh sách nhược điểm gốc a→r đã đóng.** Bổ sung sau phản hồi người dùng: đồng bộ `ALL_TIMEFRAMES` (15m/30m/1h/4h/1d/1w/1M) làm nguồn duy nhất thay vì 4 bản sao lệch nhau, mở Walk-Forward thêm 1d, sửa bug `tf_minutes` thiếu "1d" ở backend; thêm `formatVN*` (GMT+7 tường minh) thay toàn bộ chỗ hiển thị giờ UTC/local-browser sai trước đó (10 vị trí). |