diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages')
| -rw-r--r-- | frontend/src/pages/AccountPage.svelte | 189 | ||||
| -rw-r--r-- | frontend/src/pages/AdminPage.svelte | 371 | ||||
| -rw-r--r-- | frontend/src/pages/AuthPages.svelte | 187 | ||||
| -rw-r--r-- | frontend/src/pages/DatasetsPage.svelte | 8 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectBacktestTab.svelte | 384 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectDataTab.svelte | 85 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectPage.svelte | 23 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectResultsTab.svelte | 299 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectStrategyTab.svelte | 420 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectsPage.svelte | 112 | ||||
| -rw-r--r-- | frontend/src/pages/RunsPage.svelte | 180 | ||||
| -rw-r--r-- | frontend/src/pages/UsagePage.svelte | 84 |
12 files changed, 2342 insertions, 0 deletions
diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte new file mode 100644 index 0000000..ef6d1a3 --- /dev/null +++ b/frontend/src/pages/AccountPage.svelte @@ -0,0 +1,189 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { session } from '../lib/session.svelte'; + import { getSessions, patchProfile, postPassword, deleteSession } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime } from '../lib/format'; + import type { SessionInfo } from '../lib/types'; + + let nameInput = $state(session.me?.name ?? ''); + let profileBusy = $state(false); + let profileOk = $state<string | null>(null); + let profileErr = $state<string | null>(null); + + let curPw = $state(''); + let newPw = $state(''); + let newPw2 = $state(''); + let pwBusy = $state(false); + let pwErr = $state<string | null>(null); + let pwOk = $state<string | null>(null); + + let sessions: SessionInfo[] = $state([]); + let sessBusy = $state(true); + let sessErr = $state<string | null>(null); + let sessMsg = $state<string | null>(null); + + $effect(() => { + nameInput = session.me?.name ?? ''; + void loadSessions(); + }); + + async function loadSessions() { + sessBusy = true; + sessErr = null; + try { + const { items } = await getSessions(); + sessions = items; + } catch (e) { + sessErr = extractStatusMessage(e); + } finally { + sessBusy = false; + } + } + + async function saveProfile(e: SubmitEvent) { + e.preventDefault(); + profileBusy = true; + profileOk = null; + profileErr = null; + try { + await patchProfile(nameInput.trim()); + profileOk = '资料已更新'; + await session.refresh(); + } catch (err) { + profileErr = extractStatusMessage(err); + } finally { + profileBusy = false; + } + } + + async function changePassword(e: SubmitEvent) { + e.preventDefault(); + pwErr = null; + if (newPw.length < 8) { + pwErr = '新密码长度需至少 8 位'; + return; + } + if (newPw !== newPw2) { + pwErr = '两次输入的新密码不一致'; + return; + } + pwBusy = true; + try { + await postPassword(curPw, newPw); + pwOk = '密码已更新;其他会话已被登出,请用新密码重新登录本机外的应用。'; + curPw = ''; + newPw = ''; + newPw2 = ''; + } catch (err) { + pwErr = extractStatusMessage(err); + } finally { + pwBusy = false; + } + } + + async function revoke(s: SessionInfo) { + sessMsg = null; + sessErr = null; + try { + await deleteSession(s.id); + sessMsg = s.current ? '该会话已注销;如果你注销的是当前会话,会稍后自动退出。' : '会话已撤销'; + await loadSessions(); + await session.refresh(); + } catch (e) { + sessErr = extractStatusMessage(e); + } + } +</script> + +<Page title="账户与安全" subtitle="资料、密码与个人会话管理"> + <div class="stack"> + <section class="card stack"> + <h2>个人信息</h2> + <form onsubmit={saveProfile} class="row"> + <label style="flex:1 1 200px"> + 姓名 + <input bind:value={nameInput} autocomplete="name" /> + </label> + <div> + 邮箱 + <div class="fixed">{session.me?.email ?? '—'}</div> + </div> + <div> + 角色 + <div>{session.me?.role === 'admin' ? '管理员' : '成员'}{session.me?.active === false ? ' · 已停用' : ''}</div> + </div> + <button class="btn primary" disabled={profileBusy || nameInput.trim().length === 0}> + {profileBusy ? '保存中…' : '保存资料'} + </button> + </form> + <Status busy={false} error={profileErr} empty={profileOk} /> + </section> + + <section class="card stack"> + <h2>修改密码</h2> + <form onsubmit={changePassword} class="stack"> + <div class="grid3 wrap-3"> + <label> + 当前密码 + <input type="password" bind:value={curPw} required autocomplete="current-password" /> + </label> + <label> + 新密码 + <input type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} /> + </label> + <label> + 确认新密码 + <input type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} /> + </label> + </div> + <div class="row"> + <button class="btn primary" disabled={pwBusy}> + {pwBusy ? '更新中…' : '更新密码'} + </button> + </div> + <Status busy={false} error={pwErr} empty={pwOk} /> + </form> + </section> + + <section class="card stack"> + <h2>登录会话</h2> + <Status busy={sessBusy} error={sessErr} busyText="载入会话…" empty={null} /> + {#if !sessBusy && !sessErr && sessions.length === 0} + <Status busy={false} empty="没有其他会话记录" /> + {/if} + {#if sessions.length > 0} + <table class="data"> + <thead> + <tr> + <th>创建</th> + <th>最近活跃</th> + <th>设备/UA</th> + <th></th> + </tr> + </thead> + <tbody> + {#each sessions as s (s.id)} + <tr> + <td>{fmtDateTime(s.created_at)}</td> + <td>{fmtDateTime(s.last_seen_at)}</td> + <td class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</td> + <td> + <div class="row"> + {#if s.current}<span class="badge teal">当前会话</span>{/if} + <button class="btn small danger" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}> + {s.current ? '注销当前' : '撤销'} + </button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <Status busy={false} error={null} empty={sessMsg} /> + <p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p> + </section> + </div> +</Page> diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte new file mode 100644 index 0000000..984962d --- /dev/null +++ b/frontend/src/pages/AdminPage.svelte @@ -0,0 +1,371 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Modal from '../components/Modal.svelte'; + import { + listAdminUsers, + patchAdminUser, + createInvitation, + listInvitations, + deleteInvitation, + getAdminAudit, + postAdminUserPassword + } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime } from '../lib/format'; + import type { User, Invitation } from '../lib/types'; + + let tab = $state<'users' | 'invites' | 'audit'>('users'); + + let users: User[] = $state([]); + let usersBusy = $state(true); + let usersErr = $state<string | null>(null); + let resetFor = $state<User | null>(null); + let resetToken = $state(''); + let resetting = $state(false); + let adminOpsErr = $state<string | null>(null); + let inviteCopied = $state(false); + + let invites: Invitation[] = $state([]); + let inviteBusy = $state(true); + let inviteErr = $state<string | null>(null); + let inviteEmail = $state(''); + let inviteHours = $state(72); + let creatingInvite = $state(false); + let newInvite = $state<{ token: string; expires_at: string } | null>(null); + let gotToken = $state(false); + + let audit: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] = $state([]); + let auditBusy = $state(true); + let auditErr = $state<string | null>(null); + + $effect(() => { + void loadTab(tab); + }); + + async function loadTab(t: 'users' | 'invites' | 'audit') { + if (t === 'users') await loadUsers(); + else if (t === 'invites') await loadInvites(); + else await loadAudit(); + } + + async function loadUsers() { + usersBusy = true; + usersErr = null; + try { + const { items } = await listAdminUsers(); + users = items; + } catch (e) { + usersErr = extractStatusMessage(e); + } finally { + usersBusy = false; + } + } + + async function loadInvites() { + inviteBusy = true; + inviteErr = null; + try { + const { items } = await listInvitations(); + invites = items; + } catch (e) { + inviteErr = extractStatusMessage(e); + } finally { + inviteBusy = false; + } + } + + async function loadAudit() { + auditBusy = true; + auditErr = null; + try { + const res = await getAdminAudit(); + audit = res.items; + } catch (e) { + auditErr = extractStatusMessage(e); + } finally { + auditBusy = false; + } + } + + async function patchUser(u: User, patch: { active?: boolean; role?: 'admin' | 'member'; daily_run_limit?: number | null; ai_enabled?: boolean }) { + adminOpsErr = null; + try { + const fresh = await patchAdminUser(u.id, patch); + users = users.map((x) => (x.id === fresh.id ? fresh : x)); + } catch (e) { + adminOpsErr = extractStatusMessage(e); + } + } + + async function issueReset(u: User) { + resetFor = u; + resetToken = ''; + adminOpsErr = null; + resetting = true; + try { + const res = await postAdminUserPassword(u.id); + resetToken = res.reset_token; + } catch (e) { + adminOpsErr = extractStatusMessage(e); + resetFor = null; + } finally { + resetting = false; + } + } + + async function makeInvite(e: SubmitEvent) { + e.preventDefault(); + creatingInvite = true; + inviteErr = null; + newInvite = null; + try { + const res = await createInvitation({ + email: inviteEmail.trim() || undefined, + expires_hours: inviteHours + }); + newInvite = res; + inviteEmail = ''; + await loadInvites(); + } catch (err) { + inviteErr = extractStatusMessage(err); + } finally { + creatingInvite = false; + } + } + + async function revokeInvite(i: Invitation) { + adminOpsErr = null; + try { + await deleteInvitation(i.id); + await loadInvites(); + } catch (e) { + adminOpsErr = extractStatusMessage(e); + } + } + + async function copy(text: string) { + try { + await navigator.clipboard.writeText(text); + inviteCopied = true; + } catch { + /* clipboard unavailable; user will select manually */ + } + } + + const inviteUrl = $derived( + newInvite + ? `${window.location.origin}${window.location.pathname}#/register?invite=${encodeURIComponent(newInvite.token)}` + : '' + ); + const resetUrl = $derived( + resetToken + ? `${window.location.origin}${window.location.pathname}#/reset?token=${encodeURIComponent(resetToken)}` + : '' + ); +</script> + +<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码"> + {#snippet actions()} + <div class="tabs" role="tablist"> + <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button> + <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button> + <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button> + </div> + {/snippet} + + <div class="stack"> + <Status busy={false} error={adminOpsErr} empty={null} /> + + {#if tab === 'users'} + <section class="card stack"> + <h2>用户</h2> + <Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} /> + {#if !usersBusy && !usersErr && users.length > 0} + <table class="data"> + <thead> + <tr> + <th>邮箱</th> + <th>姓名</th> + <th>角色</th> + <th>状态</th> + <th>AI 权限</th> + <th>日回测上限</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each users as u (u.id)} + <tr> + <td>{u.email}</td> + <td>{u.name}</td> + <td> + <select + value={u.role} + aria-label={`角色 ${u.email}`} + onchange={(ev) => void patchUser(u, { role: (ev.target as HTMLSelectElement).value as 'admin' | 'member' })} + > + <option value="member" selected={u.role === 'member'}>成员</option> + <option value="admin" selected={u.role === 'admin'}>管理员</option> + </select> + </td> + <td> + <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span> + </td> + <td> + <label class="checkline"> + <input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} /> + </label> + </td> + <td> + <input + class="num" + style="width:90px" + type="number" + min="0" + max="200" + value={u.daily_run_limit ?? 5} + onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })} + /> + </td> + <td> + <div class="row"> + <button + class="btn small {u.active ? 'danger' : ''}" + onclick={() => void patchUser(u, { active: !u.active })} + > + {u.active ? '停用' : '启用'} + </button> + <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p> + </section> + {:else if tab === 'invites'} + <section class="card stack"> + <h2>签发邀请</h2> + <form onsubmit={makeInvite} class="row"> + <label style="flex:1 1 220px"> + 邮箱(可选备注) + <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" /> + </label> + <label style="flex:0 0 130px"> + 有效小时 + <input type="number" bind:value={inviteHours} min="1" max="720" /> + </label> + <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button> + </form> + <Status busy={false} error={inviteErr} empty={null} /> + {#if newInvite} + <div class="banner info"> + <div> + <div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div> + <div class="mono-invite">{newInvite.token}</div> + <div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div> + <div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div> + <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button> + </div> + </div> + {/if} + </section> + + <section class="card stack"> + <h2>历史邀请</h2> + <Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} /> + {#if !inviteBusy && !inviteErr && invites.length === 0} + <Status busy={false} empty="还没有邀请记录" /> + {/if} + {#if !inviteBusy && invites.length > 0} + <table class="data"> + <thead> + <tr> + <th>邮箱备注</th> + <th>角色</th> + <th>创建时间</th> + <th>过期</th> + <th>状态</th> + <th></th> + </tr> + </thead> + <tbody> + {#each invites as inv (inv.id)} + <tr> + <td>{inv.email ?? '—'}</td> + <td>{inv.role === 'admin' ? '管理员' : '成员'}</td> + <td>{fmtDateTime(inv.created_at)}</td> + <td>{fmtDateTime(inv.expires_at)}</td> + <td> + {#if inv.used_at}<span class="badge grey">已使用</span> + {:else if inv.revoked}<span class="badge grey">已撤销</span> + {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span> + {:else}<span class="badge teal">有效</span> + {/if} + </td> + <td> + {#if !inv.used_at && !inv.revoked} + <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button> + {/if} + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p> + </section> + {:else} + <section class="card stack"> + <h2>安全审计(收尾尾部)</h2> + <Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} /> + {#if !auditBusy && !auditErr && audit.length === 0} + <Status busy={false} empty="暂无审计事件" /> + {/if} + {#if !auditBusy && audit.length > 0} + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>行为者</th> + <th>事件</th> + <th>对象</th> + <th>结果</th> + </tr> + </thead> + <tbody> + {#each audit as e, i (i)} + <tr> + <td>{fmtDateTime(e.time)}</td> + <td>{e.actor ?? '—'}</td> + <td>{e.action}</td> + <td class="num">{e.target ?? '—'}</td> + <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p> + </section> + {/if} + </div> + + {#if resetFor} + <Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}> + <div class="stack"> + <div class="banner info"> + <div> + <div>恢复链接(管理员线下告知对方,只出现一次):</div> + <div class="mono-invite">{resetUrl}</div> + <div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div> + </div> + </div> + <p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p> + </div> + </Modal> + {/if} +</Page> diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte new file mode 100644 index 0000000..9e56be2 --- /dev/null +++ b/frontend/src/pages/AuthPages.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import { session } from '../lib/session.svelte'; + import { go } from '../lib/router'; + import { postLogin, postRegister, postResetPassword } from '../lib/client'; + import { extractStatusMessage, statusByName } from '../lib/state'; + + interface Props { + route: Route; + } + let { route }: Props = $props(); + + const mode = $derived(route.name); + + let email = $state(''); + let password = $state(''); + let name = $state(''); + // Initial route query is deliberately read once: token is consumed at page load. + // svelte-ignore state_referenced_locally + let inviteToken = $state(route.query.get('invite') ?? ''); + // svelte-ignore state_referenced_locally + let resetToken = $state(route.query.get('token') ?? ''); + let busy = $state(false); + let resultError = $state<string | null>(null); + let done = $state<string | null>(null); + let pw2 = $state(''); + let newPassword = $state(''); + + async function login(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + await postLogin(email.trim(), password); + const user = await session.refresh(); + if (user) session.set(user); + go('/projects'); + } catch (err) { + if (statusByName(err) === 'unauthorized') resultError = '邮箱或密码不正确,或账户已被停用'; + else resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } + + async function register(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + const { user } = await postRegister(inviteToken.trim(), name.trim(), email.trim(), password); + session.set(user); + go('/projects'); + } catch (err) { + resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } + + async function reset(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + if (!resetToken.trim()) { + resultError = '链接中的重置令牌未提供。请使用管理员发给您的完整恢复链接打开本页。'; + return; + } + if (newPassword.length < 8) { + resultError = '新密码长度需至少 8 位'; + return; + } + if (newPassword !== pw2) { + resultError = '两次输入的新密码不一致'; + return; + } + await postResetPassword(resetToken.trim(), newPassword); + done = '密码已重置,现在可以用新密码登录。'; + } catch (err) { + resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } +</script> + +<section class="auth-wrap"> + <div class="auth-card card" role="main"> + <div class="auth-brand"> + <span class="mark" aria-hidden="true"></span> + <h1>策研 <span class="sub">Strategy Lab</span></h1> + <p class="hint">内部策略研究平台 · 仅限受邀成员</p> + </div> + + {#if mode === 'register'} + <form onsubmit={register}> + <div> + <label for="inv">邀请令牌</label> + <input id="inv" bind:value={inviteToken} required autocomplete="off" /> + <p class="hint">单次有效,来自管理员邀请</p> + </div> + <div> + <label for="nm">姓名</label> + <input id="nm" bind:value={name} required autocomplete="name" /> + </div> + <div> + <label for="em">邮箱</label> + <input id="em" type="email" bind:value={email} required autocomplete="email" /> + </div> + <div> + <label for="pw">密码</label> + <input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} /> + <div class="hint">至少 8 位</div> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '创建中…' : '开通账户'}</button> + <p class="hint">已有账户? <a href="#/login">登录</a></p> + </form> + {:else if mode === 'reset'} + {#if done} + <div class="banner info">{done}</div> + <p><a href="#/login">前往登录</a></p> + {:else} + <form onsubmit={reset}> + <div> + <label for="rt">重置令牌</label> + <input id="rt" bind:value={resetToken} autocomplete="off" /> + <p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p> + </div> + <div> + <label for="np">新密码</label> + <input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} /> + </div> + <div> + <label for="np2">再输入一次新密码</label> + <input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} /> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '重置中…' : '重置密码'}</button> + <p class="hint">想起密码了? <a href="#/login">直接登录</a></p> + </form> + {/if} + {:else} + <form onsubmit={login}> + <div> + <label for="le">邮箱</label> + <input id="le" type="email" bind:value={email} required autocomplete="email" /> + </div> + <div> + <label for="lp">密码</label> + <input id="lp" type="password" bind:value={password} required autocomplete="current-password" /> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '登录中…' : '登录'}</button> + <p class="hint"> + 开通新账户需要管理员邀请: <a href="#/register">用邀请令牌注册</a> + {#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if} + </p> + <p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p> + </form> + {/if} + </div> +</section> + +<style> + .auth-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; + background: + radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent), + var(--bg); + } + .auth-card { width: 400px; max-width: 100%; padding: 1.6rem; } + .auth-brand { margin-bottom: 1.1rem; } + .mark { + display: inline-block; + width: 34px; + height: 34px; + border-radius: 9px; + background: var(--teal-strong); + margin-bottom: 0.6rem; + } + .sub { color: var(--text-3); font-weight: 500; font-size: 0.8rem; } +</style> diff --git a/frontend/src/pages/DatasetsPage.svelte b/frontend/src/pages/DatasetsPage.svelte new file mode 100644 index 0000000..b348ee3 --- /dev/null +++ b/frontend/src/pages/DatasetsPage.svelte @@ -0,0 +1,8 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import ProjectDataTab from './ProjectDataTab.svelte'; +</script> + +<Page title="数据集" subtitle="你的所有数据请求与物化数据(跨项目共享目录,锁定标的信息与覆盖情况)"> + <ProjectDataTab /> +</Page> diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte new file mode 100644 index 0000000..baa9e5f --- /dev/null +++ b/frontend/src/pages/ProjectBacktestTab.svelte @@ -0,0 +1,384 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import { listDatasets, createRun, getRun, cancelRun, rerunRun, listRuns, getDatasetPreview } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { + fmtDateTime, + fmtPct, + formatMoney, + datasetStatusLabel, + runStatusLabel, + runStatusBadge + } from '../lib/format'; + import { summarizeCoverage } from '../lib/coverage'; + import type { Run, Dataset } from '../lib/types'; + + interface Props { + projectId: string; + } + let { projectId }: Props = $props(); + + // config inputs + let datasetsReady: Dataset[] = $state([]); + let busyDatasets = $state(true); + let cfgErr = $state<string | null>(null); + + let datasetId = $state(''); + let capital = $state<number>(1_000_000); + let commission = $state(0.0003); + let slippage = $state(0.001); + let benchmark = $state(''); + let paramsText = $state(''); + let acknowledged = $state(false); + + // life cycle + let latest = $state<Run | null>(null); + let latestErr = $state<string | null>(null); + let cancelBusy = $state(false); + let rerunBusy = $state(false); + let actionMsg = $state<string | null>(null); + + let history: Run[] = $state([]); + let historyBusy = $state(false); + let historyErr = $state<string | null>(null); + + $effect(() => { + void loadDatasets(); + void loadLatest(true); + return () => { + if (pollTimer) clearInterval(pollTimer); + }; + }); + + let pollTimer: ReturnType<typeof setInterval> | null = null; + + function isLiveStatus(r: Run | null | undefined) { + return r?.status === 'queued' || r?.status === 'running'; + } + + async function loadDatasets() { + busyDatasets = true; + cfgErr = null; + try { + const { items } = await listDatasets(); + datasetsReady = items.filter((d) => d.status === 'ready'); + if (items.some((d) => d.status === 'pending' || d.status === 'running')) { + if (!waitTimer) { + waitTimer = setInterval(() => { + void loadDatasets(); + }, 2500); + } + } else { + stopWait(); + } + } catch (e) { + cfgErr = extractStatusMessage(e); + } finally { + busyDatasets = false; + } + } + + let waitTimer: ReturnType<typeof setInterval> | null = null; + + function stopWait() { + if (waitTimer) { + clearInterval(waitTimer); + waitTimer = null; + } + } + + async function loadLatest(startPolling: boolean) { + latestErr = null; + try { + const res = await listRuns(projectId); + history = res.items; + latest = res.items[0] ?? null; + if (startPolling && isLiveStatus(latest) && !pollTimer) { + pollTimer = setInterval(() => void loadLatest(false), 2000); + } + if (!isLiveStatus(latest) && pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + await refreshHistory(); + } + } catch (e) { + latestErr = extractStatusMessage(e); + } + } + + async function refreshHistory() { + historyBusy = true; + historyErr = null; + try { + const res = await listRuns(projectId); + history = res.items; + } catch (e) { + historyErr = extractStatusMessage(e); + } finally { + historyBusy = false; + } + } + + async function previewWarningsForDataset(id: string) { + try { + const preview = await getDatasetPreview(id); + const cov = summarizeCoverage(preview.coverage ?? []); + datasetWarnMsgs = [...cov.warnings, ...(preview.warnings ?? [])]; + } catch { + datasetWarnMsgs = []; + } + acknowledged = false; + } + + let datasetWarnMsgs: string[] = $state([]); + + const selectedDataset = $derived(datasetsReady.find((d) => d.id === datasetId) ?? null); + const needAck = $derived(datasetWarnMsgs.length > 0); + + $effect(() => { + if (datasetId) void previewWarningsForDataset(datasetId); + }); + + async function startRun() { + const errs: string[] = []; + if (!datasetId) errs.push('请选择一个就绪的数据集'); + if (!(capital > 0) || !Number.isFinite(capital)) errs.push('起始资金必须是正数'); + if (!(commission >= 0 && commission < 0.1)) errs.push('佣金率应在 0 与 0.1 之间'); + if (!(slippage >= 0 && slippage < 0.1)) errs.push('滑点应在 0 与 0.1 之间'); + let parameters: Record<string, unknown> | undefined; + if (paramsText.trim()) { + try { + parameters = JSON.parse(paramsText); + } catch { + errs.push('参数必须是合法 JSON'); + } + } + if (errs.length > 0) { + cfgErr = errs.join(';'); + return; + } + cfgErr = null; + if (needAck && !acknowledged) { + cfgErr = '数据覆盖有差异,请先勾选确认已知晓后再启动回测'; + return; + } + try { + const run = await createRun({ + project_id: projectId, + dataset_id: datasetId, + capital, + commission, + slippage, + benchmark_symbol: benchmark.trim() || undefined, + parameters, + acknowledge_warnings: acknowledged + }); + latest = run; + await loadLatest(true); + actionMsg = `回测已提交,编号 ${run.id.slice(0, 8)}…`; + void refreshHistory(); + } catch (e) { + cfgErr = extractStatusMessage(e); + const details = (e as { details?: { errors?: string[] } }).details?.errors; + if (details) cfgErr = details.join(';'); + } + } + + + + async function onCancel() { + if (!latest) return; + cancelBusy = true; + try { + latest = await cancelRun(latest.id); + actionMsg = '已请求取消'; + } catch (e) { + actionMsg = extractStatusMessage(e); + } finally { + cancelBusy = false; + } + } + + async function onRerun() { + if (!latest) return; + rerunBusy = true; + try { + const run = await rerunRun(latest.id, true); + latest = run; + await loadLatest(true); + actionMsg = `已用原始代码与数据重新提交,编号 ${run.id.slice(0, 8)}…`; + } catch (e) { + actionMsg = extractStatusMessage(e); + } finally { + rerunBusy = false; + } + } + + const canCancel = $derived(isLiveStatus(latest)); + const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled'); +</script> + +<div class="stack"> + <section class="card stack"> + <h2>发起回测</h2> + <Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} /> + {#if !busyDatasets && datasetsReady.length === 0 && !cfgErr} + <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" /> + {/if} + {#if datasetsReady.length > 0} + <div class="grid3 wrap-3"> + <label> + 数据集 + <select bind:value={datasetId}> + <option value="" disabled>选择数据集…</option> + {#each datasetsReady as d (d.id)} + <option value={d.id}> + {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')} + </option> + {/each} + </select> + </label> + <label> + 起始资金 + <input class="num" type="number" bind:value={capital} min="1" step="1000" /> + </label> + <div class="row2"> + <label> + 佣金率 + <input class="num" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" /> + </label> + <label> + 滑点率 + <input class="num" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" /> + </label> + </div> + <label> + 基准代码(可选) + <input bind:value={benchmark} placeholder="例如 000300.SH 指数代码" /> + </label> + <label> + 策略参数(JSON,可选) + <textarea rows="1" bind:value={paramsText} placeholder='输入 JSON 参数,例如 fast/slow 周期'></textarea> + </label> + </div> + {#if needAck} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <div> + 该数据集存在覆盖/预警信息,需要明确知晓后才能启动: + <ul> + {#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each} + </ul> + <label class="checkline"> + <input type="checkbox" bind:checked={acknowledged} /> + 我已知晓并用当前数据继续 + </label> + </div> + </div> + {/if} + <div class="row"> + <button class="btn primary" onclick={startRun} disabled={!datasetId}>开始回测</button> + {#if selectedDataset} + <span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span> + {/if} + </div> + <Status busy={false} error={cfgErr} empty={actionMsg} /> + {/if} + </section> + + <section class="card stack"> + <div class="spread"> + <h2>最近一次回测</h2> + {#if latest} + <span class="row"> + <span class="badge">Run 分支 {latest.version_id?.slice(0,8) ?? "—"}</span> + <span class="badge {runStatusBadge[latest.status]}">{runStatusLabel[latest.status]}</span> + </span> + {/if} + </div> + + {#if latest} + <Status + busy={isLiveStatus(latest)} + busyText="排队/执行中,页面会自动刷新状态…" + empty={null} + error={latestErr} + /> + <div class="rows-grid"> + <table class="data kv"> + <tbody> + <tr><th>启动时间</th><td>{fmtDateTime(latest.created_at)}</td></tr> + {#if latest.started_at}<tr><th>开始执行</th><td>{fmtDateTime(latest.started_at)}</td></tr>{/if} + {#if latest.finished_at}<tr><th>结束时间</th><td>{fmtDateTime(latest.finished_at)}</td></tr>{/if} + <tr><th>代码版本</th><td><span class="num">{latest.version_id.slice(0, 10)}</span></td></tr> + <tr><th>数据集</th><td><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></td></tr> + <tr><th>资金</th><td>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</td></tr> + </tbody> + </table> + </div> + {#if latest.error} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{latest.error}</span></div> + {/if} + {#if latest.result?.warnings?.length} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <ul class="plain-list"> + {#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + </div> + {/if} + {#if latest.status === 'succeeded' && latest.result} + <a class="btn" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</a> + {/if} + <div class="row"> + {#if canCancel} + <button class="btn danger" onclick={onCancel} disabled={cancelBusy}> + {cancelBusy ? '取消中…' : '取消回测'} + </button> + {/if} + {#if canRerun} + <button class="btn" onclick={onRerun} disabled={rerunBusy}> + {rerunBusy ? '重新提交…' : '按原始代码&数据重跑'} + </button> + {/if} + </div> + {:else} + <Status busy={false} empty="项目还没有回测记录" emptyHint="在上方选择数据集并启动一次回测" /> + {/if} + </section> + + <section class="stack"> + <h2>回测历史</h2> + <Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} /> + {#if history && history.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>启动时间</th> + <th>状态</th> + <th>资金/佣金/滑点</th> + <th>数据集</th> + <th>结果</th> + </tr> + </thead> + <tbody> + {#each history as h (h.id)} + <tr> + <td>{fmtDateTime(h.created_at)}</td> + <td>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</td> + <td> + {formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)} + </td> + <td class="num">{h.dataset_id?.slice(0, 8)}…</td> + <td> + <a class="btn small" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</a> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </section> +</div> diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte new file mode 100644 index 0000000..e3b1923 --- /dev/null +++ b/frontend/src/pages/ProjectDataTab.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import DatasetCard from '../components/DatasetCard.svelte'; + import DatasetWizard from '../components/DatasetWizard.svelte'; + import { listDatasets } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import type { Dataset } from '../lib/types'; + + interface Props { + projectId?: string; + showWizard?: boolean; + } + let { projectId: _projectId, showWizard = false }: Props = $props(); + + let items: Dataset[] = $state([]); + let busy = $state(true); + let error: string | null = $state(null); + // svelte-ignore state_referenced_locally + let wizardOpen = $state(showWizard); + + + let refreshTimer: ReturnType<typeof setInterval> | null = null; + + $effect(() => { + void load(); + return () => { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = null; + }; + }); + + async function load() { + error = null; + try { + const { items: got } = await listDatasets(); + items = got; + const active = got.some((d) => d.status === 'pending' || d.status === 'running'); + if (active && !refreshTimer) { + refreshTimer = setInterval(async () => { + try { + const { items: fresh } = await listDatasets(); + items = fresh; + const still = fresh.some((d) => d.status === 'pending' || d.status === 'running'); + if (!still && refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } catch { + /* transient error, keep polling */ + } + }, 2000); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } +</script> + +<div class="stack"> + {#if wizardOpen} + <DatasetWizard onCreated={() => void load()} /> + <button class="btn ghost" onclick={() => (wizardOpen = false)}>收起新建面板</button> + {:else} + <button class="btn primary" onclick={() => (wizardOpen = true)}>新建数据集</button> + {/if} + + <h2>我的数据集</h2> + <Status busy={busy} error={error} busyText="加载数据集列表…" empty={null} /> + + {#if !busy && !error && items.length === 0} + <Status + busy={false} + empty="还没有数据集" + emptyHint="先创建数据集:选择标的、日期范围与复权方式,提交后异步获取并给出覆盖预览" + /> + {/if} + + {#if !busy && !error && items.length > 0} + {#each items as ds (ds.id)} + <DatasetCard dsId={ds.id} /> + {/each} + {/if} +</div> diff --git a/frontend/src/pages/ProjectPage.svelte b/frontend/src/pages/ProjectPage.svelte new file mode 100644 index 0000000..b31caa3 --- /dev/null +++ b/frontend/src/pages/ProjectPage.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import ProjectDataTab from '../pages/ProjectDataTab.svelte'; + import ProjectStrategyTab from '../pages/ProjectStrategyTab.svelte'; + import ProjectBacktestTab from '../pages/ProjectBacktestTab.svelte'; + import ProjectResultsTab from '../pages/ProjectResultsTab.svelte'; + + interface Props { + route: Route; + } + let { route }: Props = $props(); +</script> + +<!-- Layout is provided once by App.svelte; wrapping here produced the doubled sidebar bug. --> +{#if route.name === 'project-data'} + <ProjectDataTab projectId={route.params.id} /> +{:else if route.name === 'project-strategy'} + <ProjectStrategyTab projectId={route.params.id} /> +{:else if route.name === 'project-backtest'} + <ProjectBacktestTab projectId={route.params.id} /> +{:else if route.name === 'project-results'} + <ProjectResultsTab projectId={route.params.id} runId={route.query.get('run') ?? undefined} /> +{/if} diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte new file mode 100644 index 0000000..4149213 --- /dev/null +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -0,0 +1,299 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption, LineSeriesOption } from 'echarts'; +import { listRuns, getRun } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { + fmtDateTime, + fmtPct, + fmtNum, + formatMoney, + runStatusLabel, + runStatusBadge, + metricsLabels, + metricsRow + } from '../lib/format'; + import type { Run, BacktestResult } from '../lib/types'; + + interface Props { + projectId: string; + runId?: string; + } + let { projectId, runId }: Props = $props(); + + let run = $state<Run | null>(null); + let busy = $state(true); + let error = $state<string | null>(null); + let history = $state<Run[]>([]); + let logsOpen = $state(false); + let ordersOpen = $state(false); + let chartMode = $state<'equity' | 'drawdown'>('equity'); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + error = null; + try { + if (!runId) { + const res = await listRuns(projectId); + history = res.items; + run = res.items[0] ?? null; + } else { + run = await getRun(runId); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const result = $derived<BacktestResult | null>(run?.result ?? null); + const hasEquity = $derived(!!result && result.equity.length > 0); + + const equityOption: EChartsOption = $derived.by(() => { + if (!result || !hasEquity) return {}; + const dates = result.equity.map((p) => p.date); + const series: LineSeriesOption[] = [ + { + name: "策略权益", + type: "line" as const, + data: result.equity.map((p) => p.equity), + showSymbol: false, + lineStyle: { width: 1.6 }, + itemStyle: { color: "#0f766e" } + }, + { + name: "现金", + type: "line" as const, + data: result.equity.map((p) => p.cash), + showSymbol: false, + lineStyle: { type: "dashed", opacity: 0.65 }, + itemStyle: { color: "#94a3b8" } + } + ]; + const bench = result.equity.map((p) => (p.benchmark === null || p.benchmark === undefined ? null : p.benchmark)); + if (bench.some((x) => x !== null && x !== undefined)) { + series.push({ + name: "基准", + type: "line" as const, + data: bench as number[], + showSymbol: false, + itemStyle: { color: "#eab308" } + }); + } + return { + title: { text: undefined }, + legend: { top: 0 }, + grid: { top: 32, left: 60, right: 24, bottom: 54 }, + tooltip: { trigger: "axis" }, + xAxis: { type: "category", data: dates }, + yAxis: { type: "value", scale: true, name: "金额" }, + series + }; + }); + + const drawdownOption: EChartsOption = $derived.by(() => { + if (!result || !hasEquity) return {}; + const dates = result.equity.map((p) => p.date); + let peak = -Infinity; + const dd = result.equity.map((p) => { + peak = Math.max(peak, p.equity); + return -((peak - p.equity) / peak) * 100; + }); + return { + grid: { top: 24, left: 60, right: 24, bottom: 54 }, + tooltip: { trigger: "axis", valueFormatter: (v) => `${(v as number).toFixed(2)}%` }, + xAxis: { type: "category", data: dates }, + yAxis: { type: "value", name: "回撤 %", max: 0 }, + series: [ + { + name: "回撤", + type: "line" as const, + data: dd, + showSymbol: false, + areaStyle: { color: "rgba(30, 74, 66, 0.25)" }, + lineStyle: { width: 1 }, + itemStyle: { color: "#134e4a" } + } + ] + }; + }); + + function switchToRun(id: string) { + runId = id; + void load(); + } + + const ml = $derived(metricsLabels()); + const mr = $derived(result ? metricsRow(result.metrics) : []); +</script> + +<div class="stack"> + <div class="spread"> + <h2>回测结果</h2> + {#if run} + <span class="row"> + <span class="hint">Run {run.id.slice(0, 8)}…</span> + <span class="badge {runStatusBadge[run.status]}">{runStatusLabel[run.status]}</span> + </span> + {/if} + </div> + + <Status busy={busy} error={error} busyText="加载结果…" empty={null} /> + + {#if !busy && !error && !run} + <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测" /> + {/if} + + {#if run && !busy && !error} + {#if history.length > 1} + <div class="row"> + <label for="runsel" class="hint" style="margin:0">查看其他回测:</label> + <select id="runsel" style="max-width:320px" value={run?.id ?? ""} onchange={(e) => switchToRun((e.target as HTMLSelectElement).value)}> + {#each history as h (h.id)} + <option value={h.id}>{fmtDateTime(h.created_at)} · {runStatusLabel[h.status]}</option> + {/each} + </select> + </div> + {/if} + + {#if run.error} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{run.error}</span></div> + {/if} + + {#if !result} + <Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} /> + {:else} + {#if result.warnings?.length} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <div> + <ul class="ul-plain"> + {#each result.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + </div> + </div> + {/if} + + <div class="card"> + {#if result.metrics} + {@const mrow = result.metrics} + <div class="metric-grid"> + <div class="metric"> + <div class="mlabel">总收益率</div> + <div class="mvalue">{fmtPct(mrow.total_return)}</div> + </div> + <div class="metric"> + <div class="mlabel">年化收益率</div> + <div class="mvalue">{fmtPct(mrow.annual_return)}</div> + </div> + <div class="metric"> + <div class="mlabel">最大回撤</div> + <div class="mvalue">{fmtPct(mrow.max_drawdown)}</div> + </div> + <div class="metric"> + <div class="mlabel">夏普比率</div> + <div class="mvalue">{fmtNum(mrow.sharpe)}</div> + </div> + <div class="metric"> + <div class="mlabel">平仓回合数</div> + <div class="mvalue">{mrow.trade_count ?? "—"}</div> + </div> + <div class="metric"> + <div class="mlabel">期末权益</div> + <div class="mvalue">{formatMoney(mrow.final_equity)}</div> + </div> + </div> + {/if} + <p class="hint"> + 平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。 + 引擎 {result.engine?.name} {result.engine?.version} + {#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if} + {#if result.peak_rss_kb}<span> · 峰值内存 {fmtNum(result.peak_rss_kb / 1024)} MB</span>{/if} + </p> + </div> + + <div class="card stack"> + <div class="row"> + <button class="btn small" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</button> + <button class="btn small" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</button> + </div> + {#if chartMode === "equity"} + <Chart option={equityOption} ariaLabel="策略权益曲线" height={340} /> + {:else} + <Chart option={drawdownOption} ariaLabel="回撤曲线" height={300} /> + {/if} + </div> + + <div class="card stack"> + <h2>订单与持仓记录</h2> + <div class="cols-2"> + <div> + <h3>成交记录 ({result.trades.length})</h3> + {#if result.trades.length > 0} + <div class="table-wrap"> + <table class="data"> + <thead> + <tr> + <th>日期</th> + <th>标的</th> + <th>方向</th> + <th>数量</th> + <th>价格</th> + <th>金额</th> + <th>佣金</th> + </tr> + </thead> + <tbody> + {#each result.trades.slice(0, 80) as t, i (i)} + <tr> + <td>{t.date}</td> + <td class="num">{t.symbol}</td> + <td>{t.side === "buy" ? "买入" : "卖出"}</td> + <td class="num">{t.quantity}</td> + <td class="num">{fmtNum(t.price, 3)}</td> + <td class="num">{fmtNum(t.value)}</td> + <td class="num">{fmtNum(t.commission, 4)}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {#if result.trades.length > 80} + <p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p> + {/if} + {:else} + <p class="hint">本区间未发生成交</p> + {/if} + </div> + <div> + <h3>策略日志</h3> + <button class="btn small" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}> + {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`} + </button> + {#if logsOpen} + <pre class="logs">{result.logs.join("\n")}</pre> + {/if} + </div> + </div> + <div> + <h3>数据与引擎 + <span class="hint">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span> + </h3> + <p class="hint"> + 结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。 + </p> + </div> + </div> + {/if} + {/if} + + {#if !busy && !error && history.length === 0 && run === null} + <Status busy={false} empty="还没有可展示的回测" emptyHint="在「回测」标签先启动一遍" /> + {/if} +</div> diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte new file mode 100644 index 0000000..5f2e242 --- /dev/null +++ b/frontend/src/pages/ProjectStrategyTab.svelte @@ -0,0 +1,420 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import CodeEditor from '../components/CodeEditor.svelte'; + import DiffView from '../components/DiffView.svelte'; + import Modal from '../components/Modal.svelte'; + import { projectsStore } from '../lib/listsStore.svelte'; + import { + getProject, + putDraft, + listVersions, + postVersion, + getVersionCode, + postRestore, + postAIAssist, + postAIAccept + } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, versionSourceLabel } from '../lib/format'; + import type { Project, Version, AIAssist } from '../lib/types'; + + interface Props { + projectId: string; + } + let { projectId }: Props = $props(); + + let code = $state(''); + let baseGeneration = $state(0); + let baseCode = $state(''); + let saveStatus = $state<'saved' | 'saving' | 'dirty' | 'conflict'>('saved'); + let saveMsg = $state(''); + let versions: Version[] = $state([]); + let versionsBusy = $state(false); + let versionsErr = $state<string | null>(null); + let snapshotBusy = $state(false); + let snapshotMsg = $state<string | null>(null); + let snapshotErr = $state<string | null>(null); + let snapshotMessage = $state(''); + let conflictServer: { generation: number; code: string } | null = $state(null); + let comparing: { label: string; before: string; after: string } | null = $state(null); + + let instruction = $state(''); + let aiBusy = $state(false); + let aiErr = $state<string | null>(null); + let aiResult = $state<AIAssist | null>(null); + let aiAcceptBusy = $state(false); + let aiAcceptErr = $state<string | null>(null); + let aiNote = $state<string | null>(null); + + let initBusy = $state(true); + let initErr = $state<string | null>(null); + let saveTimer: ReturnType<typeof setTimeout> | null = null; + + $effect(() => { + void init(); + return () => { + if (saveTimer) clearTimeout(saveTimer); + }; + }); + + async function init() { + initBusy = true; + initErr = null; + try { + const p: Project = await getProject(projectId); + projectsStore.bump(p); + code = p.draft_code ?? ''; + baseGeneration = p.draft_generation; + baseCode = code; + await refreshVersions(); + } catch (e) { + initErr = extractStatusMessage(e); + } finally { + initBusy = false; + } + } + + async function refreshVersions() { + versionsBusy = true; + versionsErr = null; + try { + const { items } = await listVersions(projectId); + versions = items; + } catch (e) { + versionsErr = extractStatusMessage(e); + } finally { + versionsBusy = false; + } + } + + function onEditorInput(v: string) { + if (saveStatus !== 'dirty') saveStatus = 'dirty'; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => void saveDraft(v), 800); + saveMsg = '输入暂停后自动保存'; + } + + async function saveDraft(payload?: string) { + const toSave = payload ?? code; + if (!toSave && !baseCode) return; + saveStatus = 'saving'; + saveMsg = '保存中…'; + try { + const p = await putDraft(projectId, toSave, baseGeneration); + code = toSave; + baseGeneration = p.draft_generation; + baseCode = toSave; + projectsStore.bump(p); + saveStatus = 'saved'; + saveMsg = `已保存 · 草稿代 ${baseGeneration}`; + if (aiNote) aiNote = null; + } catch (e) { + const err = e as { status?: number; code?: string }; + if (err.status === 409 && err.code === 'stale_generation') { + const fresh = await getProject(projectId); + saveStatus = 'conflict'; + conflictServer = { generation: fresh.draft_generation, code: fresh.draft_code ?? '' }; + saveMsg = '检测到冲突'; + } else { + saveStatus = 'dirty'; + saveMsg = extractStatusMessage(e); + } + } + } + + async function keepMine() { + if (!conflictServer) return; + try { + const p = await putDraft(projectId, code, conflictServer.generation); + baseGeneration = p.draft_generation; + baseCode = code; + saveStatus = 'saved'; + saveMsg = '已覆盖保存本机内容'; + conflictServer = null; + } catch (e) { + saveMsg = extractStatusMessage(e); + } + } + + function loadServer() { + if (!conflictServer) return; + code = conflictServer.code; + baseGeneration = conflictServer.generation; + baseCode = code; + saveStatus = 'saved'; + saveMsg = '已采用服务器草稿'; + conflictServer = null; + } + + async function snapshot() { + snapshotBusy = true; + snapshotErr = null; + snapshotMsg = null; + try { + await postVersion(projectId, snapshotMessage.trim() || '保存版本快照'); + snapshotMessage = ''; + snapshotMsg = '版本快照已创建'; + await refreshVersions(); + } catch (e) { + snapshotErr = extractStatusMessage(e); + } finally { + snapshotBusy = false; + } + } + + async function compareWithDraft(v: Version) { + versionsErr = null; + try { + const { code: vCode } = await getVersionCode(projectId, v.id); + comparing = { label: `版本 ${v.hash.slice(0, 10)} · ${v.message}`, before: vCode, after: code }; + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function compareTwoVersions(i: number) { + const v = versions[i]; + const prev = versions[i + 1]; + if (!prev) return; + try { + const [a, b] = await Promise.all([getVersionCode(projectId, v.id), getVersionCode(projectId, prev.id)]); + comparing = { + label: `${a.code ? '' : ''}${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`, + before: b.code, + after: a.code + }; + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function restore(v: Version) { + versionsErr = null; + try { + const p = await postRestore(projectId, v.id, baseGeneration); + code = p.draft_code ?? ''; + baseGeneration = p.draft_generation; + baseCode = code; + projectsStore.bump(p); + versionsErr = null; + aiNote = `已从该版本恢复为新草稿(代 ${baseGeneration}),历史未改写`; + await refreshVersions(); + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function askAI() { + if (!instruction.trim()) return; + aiBusy = true; + aiErr = null; + try { + const res = await postAIAssist(projectId, instruction.trim(), baseGeneration); + aiResult = res; + if (res.status && res.status !== 'ok') { + aiErr = `模型返回状态 ${res.status},未生成代码建议`; + } + } catch (e) { + aiErr = extractStatusMessage(e); + aiResult = null; + } finally { + aiBusy = false; + } + } + + async function acceptAI() { + if (!aiResult) return; + aiAcceptBusy = true; + aiAcceptErr = null; + try { + const p = await postAIAccept(aiResult.id, baseGeneration); + code = p.draft_code ?? aiResult.proposed_code; + baseGeneration = p.draft_generation; + baseCode = code; + projectsStore.bump(p); + aiNote = 'AI 建议已接受,生成新的代码版本'; + aiResult = null; + instruction = ''; + await refreshVersions(); + } catch (e) { + const err = e as { status?: number; code?: string }; + if (err.status === 409) { + aiAcceptErr = '草稿代已变化,建议可能失效。请重新发起 AI 说明或刷新草稿后重试。'; + } else { + aiAcceptErr = extractStatusMessage(e); + } + } finally { + aiAcceptBusy = false; + } + } + + function discardAI() { + aiResult = null; + aiErr = null; + aiAcceptErr = null; + } +</script> + +<div class="stack"> + <Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} /> + + {#if !initBusy && !initErr} + <div class="card stack"> + <div class="spread"> + <h2>策略代码(Backtrader Python)</h2> + <span class="save-status" role="status"> + {saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`} + {#if saveStatus === 'dirty'}<span class="badge warn">有未保存修改</span>{/if} + {#if saveStatus === 'saving'}<span class="badge teal">保存中…</span>{/if} + {#if saveStatus === 'conflict'}<span class="badge danger">冲突</span>{/if} + </span> + </div> + <p class="hint"> + 编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照 + </p> + <CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} /> + <div class="spread wrap"> + <input + bind:value={snapshotMessage} + placeholder="快照说明,例如:加入RSI过滤器(可选)" + style="flex:2 1 240px" + onkeydown={(ev) => { + if (ev.key === 'Enter') void snapshot(); + }} + /> + <button class="btn" disabled={snapshotBusy} onclick={snapshot}> + {snapshotBusy ? '创建快照…' : '保存版本快照'} + </button> + <button + class="btn" + onclick={() => versions[1] && void compareWithDraft(versions[1])} + disabled={!versions[1]} + > + 草稿与上一快照对比 + </button> + </div> + <Status busy={false} error={snapshotErr} empty={snapshotMsg} /> + + {#if conflictServer} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <span> + 服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。 + </span> + </div> + <DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" /> + <div class="row"> + <button class="btn primary" onclick={keepMine}>保留本机修改(覆盖服务器)</button> + <button class="btn" onclick={loadServer}>采用服务器版本</button> + </div> + {/if} + </div> + + <div class="stack"> + <h2>AI 助手</h2> + <div class="card stack"> + <label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label> + <textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea> + <div class="row"> + <button class="btn primary" disabled={aiBusy || !instruction.trim()} onclick={askAI}> + {aiBusy ? 'AI 处理中…' : '获取建议'} + </button> + </div> + {#if aiErr} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>{aiErr}</span> + </div> + {/if} + + {#if aiResult} + <div class="stack"> + <div class="spread"> + <h3>建议解释 <span class="hint">{aiResult.model}</span></h3> + <span class="hint"> + tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出 + </span> + </div> + <p>{aiResult.explanation}</p> + <h3>完整建议源码</h3> + <CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} /> + <h3>与当前草稿的差异</h3> + <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" /> + <div class="row"> + <button + class="btn primary" + disabled={aiAcceptBusy} + onclick={acceptAI} + title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''} + > + {aiAcceptBusy ? '接受中…' : '接受为新版本'} + </button> + <button class="btn ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</button> + {#if aiResult.base_generation !== baseGeneration} + <span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span> + {/if} + {#if aiAcceptErr} + <div class="banner error" role="alert">{aiAcceptErr}</div> + {/if} + </div> + </div> + {/if} + {#if aiNote} + <div class="banner info">{aiNote}</div> + {/if} + </div> + </div> + + <div class="stack"> + <h2>版本历史</h2> + <Status busy={versionsBusy} error={versionsErr} busyText="载入版本…" empty={null} /> + {#if !versionsBusy && versions.length === 0} + <Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" /> + {/if} + {#if !versionsBusy && versions.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>摘要</th> + <th>哈希</th> + <th>来源</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each versions as v, i (v.id)} + <tr> + <td>{fmtDateTime(v.created_at)}</td> + <td> + {v.message?.trim() || '(无说明)'} + <span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span> + </td> + <td class="num">{v.hash?.slice(0, 12)}</td> + <td><span class="badge grey">{versionSourceLabel[v.source]}</span></td> + <td> + <div class="row"> + <button class="btn small" onclick={() => void compareWithDraft(v)}>对比当前</button> + {#if i + 1 < versions.length} + <button class="btn small" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</button> + {/if} + <button class="btn small" onclick={() => void restore(v)}>恢复为新草稿</button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + <p class="hint">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p> + </div> + + {#if comparing} + <Modal title={comparing.label} onClose={() => (comparing = null)}> + <DiffView before={comparing.before} after={comparing.after} /> + </Modal> + {/if} + {/if} +</div> diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte new file mode 100644 index 0000000..b29a856 --- /dev/null +++ b/frontend/src/pages/ProjectsPage.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { projectsStore } from '../lib/listsStore.svelte'; + import { createProject } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { go } from '../lib/router'; + import { fmtDateTime } from '../lib/format'; + + let busy = $state(false); + let error = $state<string | null>(null); + let creating = $state(false); + let showCreate = $state(false); + let newName = $state(''); + let newDesc = $state(''); + let createErr = $state<string | null>(null); + + async function load() { + busy = true; + error = null; + try { + await projectsStore.load(); + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + async function create(e: SubmitEvent) { + e.preventDefault(); + if (!newName.trim()) return; + creating = true; + createErr = null; + try { + const p = await createProject(newName.trim(), newDesc.trim() || undefined); + projectsStore.bump(p); + showCreate = false; + newName = ''; + newDesc = ''; + go(`/projects/${p.id}/data`); + } catch (err) { + createErr = extractStatusMessage(err); + } finally { + creating = false; + } + } +</script> + +<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录"> + {#snippet actions()} + <button class="btn primary" onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}> + {showCreate ? '取消新建' : '新建项目'} + </button> + {/snippet} + + <div class="stack"> + <Status {busy} {error} busyText="加载项目列表…" empty={null} /> + {#if showCreate} + <form class="card stack" onsubmit={create}> + <h2>新建项目</h2> + <div> + <label for="pn">项目名称</label> + <input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" /> + </div> + <div> + <label for="pd">简介(可选)</label> + <input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" /> + </div> + {#if createErr}<div class="banner error" role="alert">{createErr}</div>{/if} + <div class="row"> + <button type="submit" class="btn primary" disabled={creating || !newName.trim()}> + {creating ? '创建中…' : '创建项目'} + </button> + </div> + </form> + {/if} + + {#if !busy && !error && projectsStore.all.length === 0} + <Status busy={false} empty="还没有项目" emptyHint="点击右上角「新建项目」开始你的第一个策略研究" /> + {/if} + + {#if projectsStore.all.length > 0} + <div class="card" style="padding:0.4rem 0.6rem;"> + <table class="data"> + <thead> + <tr> + <th>名称</th> + <th>简介</th> + <th>版本代</th> + <th>最近更新</th> + </tr> + </thead> + <tbody> + {#each projectsStore.all as p (p.id)} + <tr> + <td><a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a></td> + <td class="hint">{p.description ?? '—'}</td> + <td class="num">{p.draft_generation ?? '—'}</td> + <td>{fmtDateTime(p.updated_at)}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </div> +</Page> + +<style> + .p-name { font-weight: 600; } +</style> diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte new file mode 100644 index 0000000..bd77892 --- /dev/null +++ b/frontend/src/pages/RunsPage.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption } from 'echarts'; + import { listRuns } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format'; + import type { Run } from '../lib/types'; + + let runs: Run[] = $state([]); + let busy = $state(true); + let error = $state<string | null>(null); + let chosenIds: string[] = $state([]); + let failedLoad = $state<string[]>([]); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + try { + const { items } = await listRuns(); + runs = items; + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const selected = $derived(runs.filter((r) => chosenIds.includes(r.id))); + const comparable = $derived( + selected.filter((r) => r.status === 'succeeded' && r.result) + ); + + function toggle(id: string) { + if (chosenIds.includes(id)) chosenIds = chosenIds.filter((x) => x !== id); + else chosenIds = [...chosenIds.slice(-3), id]; + } + + const compareOption: EChartsOption = $derived.by(() => { + if (comparable.length === 0) return {}; + const series = comparable.map((r, idx) => { + const eq = r.result?.equity ?? []; + const base = eq[0]?.equity ?? 1; + const colors = ['#0f766e', '#b45309', '#1d4ed8', '#8b1d8b']; + return { + name: `Run ${r.id.slice(0, 6)}.`, + type: 'line' as const, + data: eq.map((p) => p.equity / base), + showSymbol: false, + lineStyle: { width: 1.5 }, + itemStyle: { color: colors[idx % colors.length] } + }; + }); + let xLen = Math.max(...comparable.map((r) => (r.result?.equity ?? []).length), 0); + const longest = comparable.reduce((best, r) => ((r.result?.equity ?? []).length > best.length ? r.result!.equity : best), [] as { date: string }[]); + return { + legend: { top: 0 }, + grid: { top: 32, left: 64, right: 20, bottom: 50 }, + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: longest.map((p) => p.date) }, + yAxis: { type: 'value', scale: true, name: '归一净值' }, + series + }; + }); + + const configDiffRows = $derived.by(() => { + if (selected.length < 2) return []; + const keys: (keyof Run)[] = ['project_id', 'version_id', 'dataset_id']; + const cfgKeys: (keyof NonNullable<Run['config']>)[] = ['capital', 'commission', 'slippage', 'benchmark_symbol']; + const rows: { label: string; vals: string[]; differing: boolean }[] = []; + const mk = (label: string, vals: unknown[]) => { + const valsStr = vals.map((v) => (v === null || v === undefined || v === '' ? '—' : v instanceof Date ? String(v) : String(v))); + rows.push({ label, vals: valsStr, differing: new Set(valsStr).size > 1 }); + }; + for (const k of keys) mk(k === 'project_id' ? '项目' : k === 'version_id' ? '代码版本' : '数据集', selected.map((r) => r[k])); + for (const k of cfgKeys) { + mk( + k === 'capital' ? '起始资金' : k === 'commission' ? '佣金率' : k === 'slippage' ? '滑点' : '基准', + selected.map((r) => r.config?.[k]) + ); + } + mk('状态', selected.map((r) => runStatusLabel[r.status])); + mk('时间', selected.map((r) => r.created_at)); + return rows; + }); +</script> + +<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在加载实验…" empty={null} /> + + {#if !busy && !error && runs.length === 0} + <Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" /> + {/if} + + {#if !busy && !error && runs.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th></th> + <th>时间</th> + <th>状态</th> + <th>项目</th> + <th>数据集</th> + <th>资金/佣金/滑点</th> + <th>收益</th> + <th>最大回撤</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each runs as r (r.id)} + <tr> + <td> + <input + type="checkbox" + checked={chosenIds.includes(r.id)} + onchange={() => toggle(r.id)} + aria-label={`选择 Run ${r.id.slice(0, 8)} 参与`} + disabled={r.status !== 'succeeded' && chosenIds.length >= 4 && !chosenIds.includes(r.id)} + /> + </td> + <td>{fmtDateTime(r.created_at)}</td> + <td><span class="badge {runStatusBadge[r.status]}">{runStatusLabel[r.status]}</span></td> + <td class="num">{r.project_id?.slice(0, 8)}…</td> + <td class="num">{r.dataset_id?.slice(0, 8)}…</td> + <td>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</td> + <td>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</td> + <td>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</td> + <td> + <a class="btn small" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</a> + </td> + </tr> + {/each} + </tbody> + </table> + <p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p> + </div> + + {#if selected.length > 0} + <div class="card stack"> + <h2>条件差异</h2> + <table class="data"> + <thead> + <tr> + <th>维度</th> + {#each selected as r (r.id)}<th>Run {r.id.slice(0, 6)}</th>{/each} + </tr> + </thead> + <tbody> + {#each configDiffRows as row, i (i)} + <tr class:diffrow={row.differing}> + <th>{row.label}</th> + {#each row.vals as v, j (j)} + <td class={row.differing ? 'num-diff num' : 'num'}>{v}</td> + {/each} + </tr> + {/each} + </tbody> + </table> + </div> + + {#if comparable.length > 1} + <div class="card stack"> + <h2>收益曲线对齐比较(首日归一)</h2> + <Chart option={compareOption} ariaLabel="多回测对比曲线" height={340} /> + <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p> + </div> + {:else} + <Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" /> + {/if} + {/if} + {/if} + </div> +</Page> diff --git a/frontend/src/pages/UsagePage.svelte b/frontend/src/pages/UsagePage.svelte new file mode 100644 index 0000000..88dd8a5 --- /dev/null +++ b/frontend/src/pages/UsagePage.svelte @@ -0,0 +1,84 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { getAIUsage } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, fmtNum } from '../lib/format'; + import type { AIUsage } from '../lib/types'; + + let usage = $state<AIUsage | null>(null); + let busy = $state(true); + let error = $state<string | null>(null); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + error = null; + try { + usage = await getAIUsage(); + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } +</script> + +<Page title="AI 用量" subtitle="内部 POC 阶段的用量计量与限额参考。不含报价或计费"> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在载入用量…" empty={null} /> + {#if usage} + <div class="card"> + <div class="row spaced"> + <div> + <div class="mlabel">模型请求</div> + <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div> + </div> + <div> + <div class="mlabel">输入 tokens</div> + <div class="mvalue">{usage.totals?.input_tokens ?? '—'}</div> + </div> + <div> + <div class="mlabel">输出 tokens</div> + <div class="mvalue">{usage.totals?.output_tokens ?? '—'}</div> + </div> + </div> + {#if usage.internal_poc} + <p class="hint">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p> + {/if} + </div> + + {#if usage.items.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>模型</th> + <th>输入</th> + <th>输出</th> + <th>状态</th> + </tr> + </thead> + <tbody> + {#each usage.items as it, i (it.id)} + <tr> + <td>{fmtDateTime(it.created_at)}</td> + <td>{it.model}</td> + <td class="num">{it.input_tokens ?? '—'}</td> + <td class="num">{it.output_tokens ?? '—'}</td> + <td class="badge">{it.status ?? '—'}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {:else} + <Status busy={false} empty="尚未使用 AI 建议" emptyHint="在项目「策略」标签发起一次 AI 修改,记录会出现在这里" /> + {/if} + {/if} + </div> +</Page> |
