Add duplicate run button and improve RunConfigPopup UX in Intraday ORB Backtest

- Add Copy button per run row in Run History table; opens pre-filled RunConfigPopup
  with original run's period/universe/compound_returns settings
- Make RunConfigPopup larger (500px wide) and more spacious with proper header/X button
- Prevent dialog from closing on backdrop click; only Cancel or X button closes it
- Range date inputs laid out side-by-side in 2-column grid

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 6 months ago
parent ebfada24cb
commit fdd56ff683

File diff suppressed because one or more lines are too long

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fithia2</title>
<script type="module" crossorigin src="/assets/index-BD4c36oh.js"></script>
<script type="module" crossorigin src="/assets/index-DAmIlbXo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVDbFxMk.css">
</head>
<body>

@ -751,11 +751,13 @@ interface RunConfig {
function RunConfigPopup({
strategy,
initialConfig,
onRun,
onClose,
isSubmitting,
}: {
strategy: IntradayStrategy;
initialConfig?: Partial<RunConfig>;
onRun: (cfg: RunConfig) => void;
onClose: () => void;
isSubmitting: boolean;
@ -769,6 +771,7 @@ function RunConfigPopup({
universe: strategy.universe,
compound_returns: strategy.compound_returns ?? false,
initialCapital: strategy.initial_capital ?? 10000,
...initialConfig,
});
const set = <K extends keyof RunConfig>(k: K, v: RunConfig[K]) => setCfg(c => ({ ...c, [k]: v }));
@ -780,15 +783,33 @@ function RunConfigPopup({
</button>
);
const labelStyle: React.CSSProperties = {
fontSize: 12, color: 'var(--text3)', display: 'block',
marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.07em', fontWeight: 600,
};
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}>
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 10, padding: '16px 18px', width: 340, boxShadow: '0 8px 24px rgba(0,0,0,0.25)' }}
onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text1)', marginBottom: 14 }}>Run: {strategy.name}</div>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.07em' }}>Period</div>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 7, overflow: 'hidden' }}>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: 'var(--bg1)', border: '1px solid var(--border)', borderRadius: 12, padding: '28px 32px', width: 500, boxShadow: '0 12px 40px rgba(0,0,0,0.35)' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
<div>
<div style={{ fontSize: 17, fontWeight: 700, color: 'var(--text1)', marginBottom: 3 }}>Run Backtest</div>
<div style={{ fontSize: 13, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>{strategy.name}</div>
</div>
<button onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', padding: '2px 4px', borderRadius: 4, display: 'flex', lineHeight: 1 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text1)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<XCircle size={18} />
</button>
</div>
{/* Period */}
<div style={{ marginBottom: 18 }}>
<label style={labelStyle}>Period</label>
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
{modeBtn('days', 'Last N')}
{modeBtn('year', 'Year')}
{modeBtn('range', 'Range')}
@ -796,59 +817,63 @@ function RunConfigPopup({
</div>
{cfg.dateMode === 'days' && (
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.07em' }}>Trading Days</label>
<div style={{ marginBottom: 18 }}>
<label style={labelStyle}>Trading Days</label>
<input type="number" value={cfg.days} onChange={e => set('days', Number(e.target.value))} min={10} max={500} style={inputStyle} />
</div>
)}
{cfg.dateMode === 'year' && (
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.07em' }}>Year</label>
<div style={{ marginBottom: 18 }}>
<label style={labelStyle}>Year</label>
<input type="text" value={cfg.year} onChange={e => set('year', e.target.value)} placeholder="e.g. 2025" style={inputStyle} />
</div>
)}
{cfg.dateMode === 'range' && (
<div style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ marginBottom: 18, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.07em' }}>Start (YYYY / YYYY-MM / YYYY-MM-DD)</label>
<input type="text" value={cfg.startDate} onChange={e => set('startDate', e.target.value)} placeholder="e.g. 2024 or 2024-01-01" style={inputStyle} />
<label style={labelStyle}>Start Date</label>
<input type="text" value={cfg.startDate} onChange={e => set('startDate', e.target.value)} placeholder="YYYY / YYYY-MM / YYYY-MM-DD" style={inputStyle} />
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.07em' }}>End (optional — YYYY / YYYY-MM / YYYY-MM-DD)</label>
<input type="text" value={cfg.endDate} onChange={e => set('endDate', e.target.value)} placeholder="e.g. 2024 or 2024-12-31 (default: today)" style={inputStyle} />
<label style={labelStyle}>End Date <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></label>
<input type="text" value={cfg.endDate} onChange={e => set('endDate', e.target.value)} placeholder="default: today" style={inputStyle} />
</div>
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.07em' }}>Initial Capital ($)</label>
<input type="number" value={cfg.initialCapital} onChange={e => set('initialCapital', Number(e.target.value))} min={1000} step={1000} style={inputStyle} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.07em' }}>Universe</label>
<select value={cfg.universe} onChange={e => set('universe', e.target.value)} style={selectStyle}>
{UNIVERSE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{/* Two-column row: Capital + Universe */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 18 }}>
<div>
<label style={labelStyle}>Initial Capital ($)</label>
<input type="number" value={cfg.initialCapital} onChange={e => set('initialCapital', Number(e.target.value))} min={1000} step={1000} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Universe</label>
<select value={cfg.universe} onChange={e => set('universe', e.target.value)} style={selectStyle}>
{UNIVERSE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 10px', background: 'var(--bg2)', borderRadius: 7, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em' }}>Returns</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* Returns toggle */}
<div style={{ marginBottom: 28, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', background: 'var(--bg2)', borderRadius: 8, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.07em', fontWeight: 600 }}>Returns Mode</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, color: cfg.compound_returns ? 'var(--text3)' : 'var(--cyan)', fontWeight: cfg.compound_returns ? 400 : 600 }}>단리</span>
<div onClick={() => set('compound_returns', !cfg.compound_returns)}
style={{ width: 40, height: 22, borderRadius: 11, background: cfg.compound_returns ? 'var(--cyan)' : 'var(--bg3)', border: '1px solid var(--border)', cursor: 'pointer', position: 'relative', transition: 'background 0.2s' }}>
<div style={{ position: 'absolute', top: 2, left: cfg.compound_returns ? 19 : 2, width: 16, height: 16, borderRadius: '50%', background: 'white', transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }} />
style={{ width: 42, height: 24, borderRadius: 12, background: cfg.compound_returns ? 'var(--cyan)' : 'var(--bg3)', border: '1px solid var(--border)', cursor: 'pointer', position: 'relative', transition: 'background 0.2s' }}>
<div style={{ position: 'absolute', top: 3, left: cfg.compound_returns ? 20 : 3, width: 16, height: 16, borderRadius: '50%', background: 'white', transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.3)' }} />
</div>
<span style={{ fontSize: 13, color: cfg.compound_returns ? 'var(--cyan)' : 'var(--text3)', fontWeight: cfg.compound_returns ? 600 : 400 }}>복리</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={onClose} style={{ flex: 1, padding: '8px', borderRadius: 7, background: 'var(--bg2)', border: '1px solid var(--border)', color: 'var(--text2)', cursor: 'pointer', fontSize: 13 }}>Cancel</button>
{/* Buttons */}
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ flex: 1, padding: '10px', borderRadius: 8, background: 'var(--bg2)', border: '1px solid var(--border)', color: 'var(--text2)', cursor: 'pointer', fontSize: 14 }}>Cancel</button>
<button onClick={() => onRun(cfg)} disabled={isSubmitting}
style={{ flex: 2, padding: '8px', borderRadius: 7, background: isSubmitting ? 'var(--bg3)' : 'var(--cyan)', color: 'white', border: 'none', cursor: isSubmitting ? 'not-allowed' : 'pointer', fontSize: 13, fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<Play size={13} /> {isSubmitting ? 'Submitting…' : 'Run'}
style={{ flex: 2, padding: '10px', borderRadius: 8, background: isSubmitting ? 'var(--bg3)' : 'var(--cyan)', color: 'white', border: 'none', cursor: isSubmitting ? 'not-allowed' : 'pointer', fontSize: 14, fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7 }}>
<Play size={14} /> {isSubmitting ? 'Submitting…' : 'Run'}
</button>
</div>
</div>
@ -1065,6 +1090,7 @@ export function IntradayBacktestPage() {
const strategies = strategiesData?.strategies ?? [];
const [runningFor, setRunningFor] = useState<IntradayStrategy | null>(null);
const [duplicateTask, setDuplicateTask] = useState<IntradayTask | null>(null);
const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null);
const [modalInitial, setModalInitial] = useState<Omit<IntradayStrategy, 'slug'>>(DEFAULT_FORM);
const [editingSlug, setEditingSlug] = useState<string | null>(null);
@ -1075,6 +1101,7 @@ export function IntradayBacktestPage() {
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['intraday-tasks'] });
setRunningFor(null);
setDuplicateTask(null);
navigate(`/intraday/tasks/${data.task_id}`);
},
});
@ -1120,6 +1147,32 @@ export function IntradayBacktestPage() {
submitMutation.mutate(req);
}
function getTaskInitialConfig(task: IntradayTask): Partial<RunConfig> {
const base: Partial<RunConfig> = {
days: task.days,
universe: task.universe,
compound_returns: task.compound_returns ?? false,
};
if (task.year) return { ...base, dateMode: 'year', year: task.year };
if (task.start_date) return { ...base, dateMode: 'range', startDate: task.start_date, endDate: task.end_date ?? '' };
return { ...base, dateMode: 'days' };
}
function handleDuplicateSubmit(cfg: RunConfig) {
if (!duplicateTask) return;
const req: Parameters<typeof intradayApi.submit>[0] = {
config: duplicateTask.config, days: cfg.days, universe: cfg.universe,
compound_returns: cfg.compound_returns,
initial_capital: cfg.initialCapital,
};
if (cfg.dateMode === 'year') req.year = cfg.year;
else if (cfg.dateMode === 'range') {
req.start_date = cfg.startDate || null;
req.end_date = cfg.endDate || null;
}
submitMutation.mutate(req);
}
function handleEdit(s: IntradayStrategy) {
setEditingSlug(s.slug);
const { slug: _s, ...rest } = s;
@ -1137,7 +1190,7 @@ export function IntradayBacktestPage() {
}
return (
<div style={{ padding: '28px 32px', maxWidth: 1200, margin: '0 auto' }} onClick={() => setRunningFor(null)}>
<div style={{ padding: '28px 32px', maxWidth: 1200, margin: '0 auto' }}>
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
{/* Header */}
@ -1180,6 +1233,16 @@ export function IntradayBacktestPage() {
{runningFor && (
<RunConfigPopup strategy={runningFor} onRun={handleRunSubmit} onClose={() => setRunningFor(null)} isSubmitting={submitMutation.isPending} />
)}
{/* Duplicate run config modal */}
{duplicateTask && strategies.find(s => s.slug === duplicateTask.config) && (
<RunConfigPopup
strategy={strategies.find(s => s.slug === duplicateTask.config)!}
initialConfig={getTaskInitialConfig(duplicateTask)}
onRun={handleDuplicateSubmit}
onClose={() => setDuplicateTask(null)}
isSubmitting={submitMutation.isPending}
/>
)}
</div>
{/* Run History */}
@ -1240,13 +1303,22 @@ export function IntradayBacktestPage() {
: new Date(task.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td style={{ padding: '10px 14px' }} onClick={e => e.stopPropagation()}>
<button onClick={() => deleteTaskMutation.mutate(task.task_id)}
title="Delete"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--red)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Trash2 size={13} />
</button>
<div style={{ display: 'flex', gap: 2 }}>
<button onClick={() => setDuplicateTask(task)}
title="Duplicate Run"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--cyan)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Copy size={13} />
</button>
<button onClick={() => deleteTaskMutation.mutate(task.task_id)}
title="Delete"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text3)', padding: '2px 4px', borderRadius: 4, display: 'flex', alignItems: 'center' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--red)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text3)')}>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
);

Loading…
Cancel
Save