feat(strategy): 代码版本快照(§12.6补,方案B发起时快照·QuantConnect轻量版)——用户拍板:解决「实例参数有快照但代码没有,历史运行无法回溯当时跑的哪版代码」——①sanguo_api/code_versions.py:发起时全文落盘data/strategy_code_versions/{file}.{hash8}.py(md5内容寻址天然去重,原子替换防并发坏);发起四入口(paper/live/CTA/组合回测)全快照,账户加code_hash列(ALTER迁移),回测经spec→run_meta.code_hash落档案②查看:GET /strategy/code-versions列表+单版本全文;代码编辑页「版本历史」抽屉(MonacoDiff左右对比当前,主题同款)③「代码已变更」角标:策略库档案行(enriched code_changed)+全景每run标v哈希·一致/已变更④双轨对账加「代码版本一致」第五指标(对账FAIL先查这行,两边代码不同价差必然大);路径穿越防护(版本号只认8位hex)+6测试;927绿+build绿;「用当时代码重跑」留P2 [vps]
This commit is contained in:
@@ -212,6 +212,8 @@ export interface ReconcilePair {
|
||||
shadow_account_id: number
|
||||
strategy: string
|
||||
report: ReconcileReport
|
||||
/** §12.6 补:双轨代码版本一致(null=任一侧无快照不可判;对账 FAIL 先查这行) */
|
||||
code_match?: boolean | null
|
||||
}
|
||||
|
||||
export async function getReconcilePairs(date?: string): Promise<ReconcilePair[]> {
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface RunningAccount {
|
||||
label: string
|
||||
ret: number | null
|
||||
drifted?: boolean
|
||||
code_changed?: boolean | null
|
||||
code_version?: string | null
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
@@ -78,6 +80,7 @@ export interface Instance {
|
||||
updated_at: string
|
||||
running_accounts?: RunningAccount[]
|
||||
drift?: boolean
|
||||
code_changed?: boolean | null
|
||||
}
|
||||
|
||||
export async function getInstances(): Promise<Instance[]> {
|
||||
@@ -85,6 +88,24 @@ export async function getInstances(): Promise<Instance[]> {
|
||||
return data.instances
|
||||
}
|
||||
|
||||
// ----- §12.6 补:代码版本快照 -----
|
||||
|
||||
export interface CodeVersion {
|
||||
code_version: string
|
||||
saved_at: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function getCodeVersions(file: string): Promise<CodeVersion[]> {
|
||||
const { data } = await apiClient.get<{ versions: CodeVersion[] }>('/strategy/code-versions', { params: { file } })
|
||||
return data.versions
|
||||
}
|
||||
|
||||
export async function getCodeVersion(file: string, h8: string): Promise<string> {
|
||||
const { data } = await apiClient.get<{ code: string }>(`/strategy/code-versions/${file}/${h8}`)
|
||||
return data.code
|
||||
}
|
||||
|
||||
export async function syncInstanceParams(instanceId: number): Promise<number> {
|
||||
const { data } = await apiClient.post<{ synced: number }>(`/paper/sync/${instanceId}`)
|
||||
return data.synced
|
||||
@@ -98,6 +119,8 @@ export interface OverviewRun {
|
||||
status: string | null
|
||||
ret: number | null
|
||||
equity: { date: string; equity: number }[]
|
||||
code_version?: string | null
|
||||
code_changed?: boolean | null
|
||||
}
|
||||
|
||||
export interface OverviewPosition {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
// 版本 diff 视图(§12.6 代码快照查看):左=历史版本 右=当前代码。
|
||||
// 主题与 MonacoEditor.vue 同名同值(define 幂等,两处挂载互不干扰)。
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
;(self as unknown as { MonacoEnvironment: unknown }).MonacoEnvironment = {
|
||||
getWorker: () => ({
|
||||
postMessage() {}, terminate() {}, addEventListener() {},
|
||||
removeEventListener() {}, dispatchEvent: () => true,
|
||||
}) as unknown as Worker,
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
original: string
|
||||
modified: string
|
||||
language?: string
|
||||
}>(), { language: 'python' })
|
||||
|
||||
const el = ref<HTMLDivElement>()
|
||||
let editor: monaco.editor.IStandaloneDiffEditor | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
monaco.editor.defineTheme('sanguo-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'comment', foreground: '4a5868', fontStyle: 'italic' },
|
||||
{ token: 'keyword', foreground: '00e5ff' },
|
||||
{ token: 'string', foreground: 'ffb000' },
|
||||
{ token: 'number', foreground: '2ee68a' },
|
||||
{ token: 'type', foreground: 'b48cff' },
|
||||
{ token: 'function', foreground: '7adfff' },
|
||||
{ token: 'delimiter', foreground: '7a8a9a' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#05080d',
|
||||
'editor.foreground': '#d4e4f0',
|
||||
'editorLineNumber.foreground': '#3a4654',
|
||||
'editorLineNumber.activeForeground': '#00e5ff',
|
||||
'editor.lineHighlightBackground': '#0a1018',
|
||||
'diffEditor.insertedTextBackground': '#12291c',
|
||||
'diffEditor.removedTextBackground': '#2a1418',
|
||||
},
|
||||
})
|
||||
editor = monaco.editor.createDiffEditor(el.value, {
|
||||
theme: 'sanguo-dark',
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
fontSize: 12,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
})
|
||||
setModels()
|
||||
})
|
||||
|
||||
function setModels(): void {
|
||||
if (!editor) return
|
||||
editor.setModel({
|
||||
original: monaco.editor.createModel(props.original, props.language),
|
||||
modified: monaco.editor.createModel(props.modified, props.language),
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => [props.original, props.modified], setModels)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const m = editor?.getModel()
|
||||
m?.original?.dispose()
|
||||
m?.modified?.dispose()
|
||||
editor?.dispose()
|
||||
editor = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="diff-editor"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-editor { height: 100%; min-height: 300px; }
|
||||
</style>
|
||||
@@ -410,17 +410,31 @@ export interface RunningAccountMock {
|
||||
drifted?: boolean
|
||||
}
|
||||
export const strategyInstancesEnrichedMock = {
|
||||
instances: (strategyInstancesMock.instances as Array<StrategyInstance & { running_accounts?: RunningAccountMock[]; drift?: boolean }>).map((i) => ({
|
||||
instances: (strategyInstancesMock.instances as Array<StrategyInstance & { running_accounts?: RunningAccountMock[]; drift?: boolean; code_changed?: boolean }>).map((i) => ({
|
||||
...i,
|
||||
running_accounts: i.status.paper_live === 'running'
|
||||
? [{ kind: 'paper', aid: 101, label: i.name, ret: i.last_return }]
|
||||
? [{ kind: 'paper', aid: 101, label: i.name, ret: i.last_return, code_version: 'a1b2c3d4', code_changed: i.id === 3 }]
|
||||
: (i.status.live === 'running'
|
||||
? [{ kind: 'live', aid: 201, label: `${i.name}·live`, ret: i.last_return }]
|
||||
? [{ kind: 'live', aid: 201, label: `${i.name}·live`, ret: i.last_return, code_version: 'a1b2c3d4', code_changed: false }]
|
||||
: []),
|
||||
drift: i.id === 2,
|
||||
code_changed: i.id === 3,
|
||||
})),
|
||||
}
|
||||
|
||||
/* §12.6 补:代码版本快照 mock */
|
||||
export const codeVersionsMock = (file: string) => ({
|
||||
file,
|
||||
versions: [
|
||||
{ code_version: 'a1b2c3d4', saved_at: '2026-08-14 21:05:02', size: 8213 },
|
||||
{ code_version: '9f8e7d6c', saved_at: '2026-08-10 20:31:44', size: 7920 },
|
||||
],
|
||||
})
|
||||
export const codeVersionMock = (file: string, h8: string) => ({
|
||||
file, code_version: h8,
|
||||
code: `# ${file} 快照 v${h8}(mock)\n\ndef initialize(context):\n pass\n`,
|
||||
})
|
||||
|
||||
export function instanceOverviewMock(id: number) {
|
||||
const inst = strategyInstancesMock.instances.find((i) => i.id === id) || strategyInstancesMock.instances[0]
|
||||
const days = ['07-20', '07-21', '07-22', '07-23', '07-24', '07-25', '07-26', '07-27', '07-28']
|
||||
|
||||
@@ -31,6 +31,9 @@ const routes: Route[] = [
|
||||
{ m: 'get', re: /^\/strategy\/files$/, build: () => D.strategyFilesMock },
|
||||
{ m: 'post', re: /^\/strategy\/file\/[^/]+\/check$/, build: () => ({ ok: true }) },
|
||||
{ m: 'post', re: /^\/strategy\/file\/[^/]+$/, build: () => ({ ok: true }) },
|
||||
// §12.6 补:代码版本快照(列表在前,单版本 4 段路径在后)
|
||||
{ m: 'get', re: /^\/strategy\/code-versions$/, build: (url) => D.codeVersionsMock(new URL(url, 'http://x').searchParams.get('file') || '') },
|
||||
{ m: 'get', re: /^\/strategy\/code-versions\/[^/]+\/[^/]+$/, build: (url) => { const seg = url.split('/').filter(Boolean); return D.codeVersionMock(decodeURIComponent(seg[2]), seg[3]) } },
|
||||
// strategy configs(CRUD)
|
||||
{ m: 'get', re: /^\/strategy\/configs$/, build: () => D.strategyConfigsMock },
|
||||
{ m: 'post', re: /^\/strategy\/configs$/, build: () => ({ id: 99 }) },
|
||||
|
||||
@@ -99,6 +99,13 @@ function sideText(s: string): string {
|
||||
</el-tag>
|
||||
<el-tag v-else size="small" type="info">净值序列不全</el-tag>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">代码版本一致(§12.6 快照)</span>
|
||||
<span class="stat-value mono">{{ p.code_match == null ? '—' : '双端快照' }}</span>
|
||||
<el-tag v-if="p.code_match === true" size="small" type="success">一致</el-tag>
|
||||
<el-tag v-else-if="p.code_match === false" size="small" type="danger">不一致(先查这个)</el-tag>
|
||||
<el-tag v-else size="small" type="info">无快照</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pair-nav mono muted" v-if="p.report.nav.note">
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MonacoEditor from '@/components/MonacoEditor.vue'
|
||||
import MonacoDiff from '@/components/MonacoDiff.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { getCodeVersions, getCodeVersion, type CodeVersion } from '@/api/strategy'
|
||||
|
||||
interface StrategyFile {
|
||||
name: string
|
||||
@@ -132,6 +134,35 @@ function runBacktest(): void {
|
||||
router.push({ path: '/backtest/new', query: { class: active.value.class_name } })
|
||||
}
|
||||
}
|
||||
|
||||
// ===== §12.6 补:版本历史(发起时快照)=====
|
||||
const versions = ref<CodeVersion[]>([])
|
||||
const versionsOpen = ref(false)
|
||||
const diffCode = ref<string | null>(null)
|
||||
const versionsLoading = ref(false)
|
||||
|
||||
async function openVersions(): Promise<void> {
|
||||
if (!active.value) return
|
||||
versionsOpen.value = true
|
||||
versionsLoading.value = true
|
||||
diffCode.value = null
|
||||
try {
|
||||
versions.value = await getCodeVersions(active.value.name)
|
||||
} catch {
|
||||
ElMessage.error('版本列表加载失败')
|
||||
} finally {
|
||||
versionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function showDiff(h8: string): Promise<void> {
|
||||
if (!active.value) return
|
||||
try {
|
||||
diffCode.value = await getCodeVersion(active.value.name, h8)
|
||||
} catch {
|
||||
ElMessage.error('读取版本失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -184,6 +215,7 @@ function runBacktest(): void {
|
||||
<div class="bar-right">
|
||||
<span class="modified mono">{{ active?.modified }}</span>
|
||||
<button class="term-btn sm" :disabled="checking" @click="onCheck">{{ checking ? '校验中' : '语法检查' }}</button>
|
||||
<button class="term-btn sm" :disabled="loadingCode" @click="openVersions">版本历史</button>
|
||||
<button class="term-btn sm primary" :disabled="!dirty || saving" @click="onSave">{{ saving ? '保存中' : '保存' }}</button>
|
||||
<button class="term-btn sm" :disabled="loadingCode" @click="runBacktest">运行回测</button>
|
||||
</div>
|
||||
@@ -193,6 +225,32 @@ function runBacktest(): void {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- §12.6 版本历史:发起时快照列表 + 与当前代码 diff -->
|
||||
<el-drawer :model-value="versionsOpen" size="860px" :with-header="false" append-to-body destroy-on-close @close="versionsOpen = false">
|
||||
<div v-loading="versionsLoading" class="ver-drawer">
|
||||
<div class="ver-head">
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:700;color:var(--text)">版本历史 · {{ activeName }}</div>
|
||||
<div class="muted" style="font-size:11.5px;margin-top:2px">发起回测/模拟/实盘时的代码快照(内容寻址去重);点「对比」查看与当前代码差异</div>
|
||||
</div>
|
||||
<button class="term-btn sm" @click="versionsOpen = false">关闭</button>
|
||||
</div>
|
||||
<div v-if="!versions.length && !versionsLoading" class="muted" style="padding:20px 0;text-align:center;font-size:12.5px">
|
||||
还没有快照——从策略档案发起过回测/模拟/实盘后自动生成
|
||||
</div>
|
||||
<div v-for="v in versions" :key="v.code_version" class="ver-row">
|
||||
<span class="mono" style="color:var(--brand)">v{{ v.code_version }}</span>
|
||||
<span class="muted mono" style="font-size:11px">{{ v.saved_at }}</span>
|
||||
<span class="muted mono" style="font-size:11px">{{ (v.size / 1024).toFixed(1) }} KB</span>
|
||||
<button class="term-btn sm" style="margin-left:auto" @click="showDiff(v.code_version)">对比当前</button>
|
||||
</div>
|
||||
<div v-if="diffCode != null" class="ver-diff">
|
||||
<div class="muted mono" style="font-size:11px;margin-bottom:6px">左=快照版本 · 右=当前代码</div>
|
||||
<MonacoDiff :original="diffCode" :modified="code" />
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -324,4 +382,14 @@ function runBacktest(): void {
|
||||
min-height: 0;
|
||||
background: #05080d;
|
||||
}
|
||||
|
||||
/* 版本历史抽屉 */
|
||||
.ver-drawer { display: flex; flex-direction: column; gap: 10px; padding: 4px 2px; }
|
||||
.ver-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
||||
.ver-row {
|
||||
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
|
||||
border: 1px solid var(--border-2); border-radius: var(--r-sm);
|
||||
}
|
||||
.ver-row:hover { background: var(--bg-hover); }
|
||||
.ver-diff { height: 460px; border: 1px solid var(--border-2); border-radius: var(--r-sm); overflow: hidden; }
|
||||
</style>
|
||||
|
||||
@@ -80,6 +80,9 @@ const stLabel: Record<string, string> = { running: '运行中', done: '已完成
|
||||
<span class="chip" :class="r.kind === 'live' ? 'chip-cta' : 'chip-portfolio'">{{ kindLabel[r.kind] }}</span>
|
||||
<span class="mono" style="font-size:11px">{{ r.label || `#${r.aid}` }}</span>
|
||||
<span class="muted mono" style="font-size:10px">{{ stLabel[r.status || ''] || r.status }}</span>
|
||||
<span v-if="r.code_version" class="mono" style="font-size:10px" :style="{ color: r.code_changed ? 'var(--amber)' : 'var(--text-3)' }" :title="r.code_changed ? '发起后代码已修改' : '与当前代码一致'">
|
||||
v{{ r.code_version }}{{ r.code_changed ? ' · 已变更' : '' }}
|
||||
</span>
|
||||
<span class="mono ret" :class="retClass(r.ret)" style="font-weight:700">{{ pct(r.ret) }}</span>
|
||||
</div>
|
||||
<div v-if="!data.runs.length" class="muted" style="font-size:12px;padding:8px 0">该档案还没有账户运行</div>
|
||||
|
||||
@@ -289,6 +289,7 @@ const runningRows = computed(() =>
|
||||
<div class="iname" @click="editInstance(i)">
|
||||
{{ i.name }}
|
||||
<span v-if="i.drift" class="chip drift">参数已漂移</span>
|
||||
<span v-if="i.code_changed" class="chip drift" title="发起后策略代码被修改过,在跑账户仍是旧代码">代码已变更</span>
|
||||
<span class="sub mono">#{{ i.id }} · {{ i.interval }} · 更新 {{ i.updated_at }}</span>
|
||||
</div>
|
||||
<div class="params mono muted">{{ paramSummary(i.params) }}</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""策略代码版本快照(§12.6 补:发起时快照,QuantConnect 轻量版)。
|
||||
|
||||
痛点:实例参数有发起快照,策略代码没有——历史运行无法回溯「当时跑的哪版代码」。
|
||||
方案:发起回测/模拟/实盘那一刻,把当时代码全文按内容寻址落盘
|
||||
(data/strategy_code_versions/{file}.{hash8}.py,同内容只存一份天然去重),
|
||||
运行记录只存 8 位哈希;「代码已变更」= 运行时哈希 ≠ 当前文件哈希。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
# 测试用 monkeypatch 改 _DIR
|
||||
_DIR: str = os.environ.get(
|
||||
"SANGUO_CODE_VERSIONS",
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "strategy_code_versions",
|
||||
),
|
||||
)
|
||||
_H8 = re.compile(r"^[0-9a-f]{8}$") # 防路径穿越:版本号只认 8 位十六进制
|
||||
|
||||
|
||||
def _version_path(code_file: str, h8: str) -> str:
|
||||
return os.path.join(_DIR, f"{code_file}.{h8}.py")
|
||||
|
||||
|
||||
def snapshot_code(code_file: str) -> dict | None:
|
||||
"""读当前代码 → md5 → 未存过则落盘快照。
|
||||
|
||||
返回 {code_file, code_hash, code_version(8位), saved_at};文件不存在返回 None。
|
||||
"""
|
||||
from .strategy_registry import read_strategy_file
|
||||
|
||||
if not code_file:
|
||||
return None
|
||||
try:
|
||||
src = read_strategy_file(code_file)["code"]
|
||||
except Exception:
|
||||
return None
|
||||
full = hashlib.md5(src.encode("utf-8")).hexdigest()
|
||||
h8 = full[:8]
|
||||
os.makedirs(_DIR, exist_ok=True)
|
||||
path = _version_path(code_file, h8)
|
||||
if not os.path.exists(path):
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(src)
|
||||
os.replace(tmp, path) # 原子替换防并发写坏
|
||||
return {"code_file": code_file, "code_hash": full,
|
||||
"code_version": h8, "saved_at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||||
|
||||
|
||||
def current_hash(code_file: str) -> str | None:
|
||||
"""当前文件 8 位哈希(只算不存)。文件不存在返回 None。"""
|
||||
from .strategy_registry import read_strategy_file
|
||||
|
||||
try:
|
||||
src = read_strategy_file(code_file)["code"]
|
||||
except Exception:
|
||||
return None
|
||||
return hashlib.md5(src.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def code_changed(code_file: str, code_hash: str | None) -> bool | None:
|
||||
"""运行时哈希 vs 当前文件。哈希缺失(早期账户/未解析文件)返回 None=不可判。"""
|
||||
if not code_hash:
|
||||
return None
|
||||
cur = current_hash(code_file)
|
||||
return None if cur is None else code_hash[:8] != cur
|
||||
|
||||
|
||||
def list_versions(code_file: str) -> list[dict]:
|
||||
"""该文件全部快照(新→旧):{code_version, saved_at(文件mtime), size}。"""
|
||||
if not os.path.isdir(_DIR):
|
||||
return []
|
||||
prefix = f"{code_file}."
|
||||
out = []
|
||||
for fn in os.listdir(_DIR):
|
||||
if not (fn.startswith(prefix) and fn.endswith(".py")):
|
||||
continue
|
||||
h8 = fn[len(prefix):-3]
|
||||
if not _H8.match(h8):
|
||||
continue
|
||||
st = os.stat(os.path.join(_DIR, fn))
|
||||
out.append({"code_version": h8,
|
||||
"saved_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)),
|
||||
"size": st.st_size})
|
||||
out.sort(key=lambda v: v["saved_at"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def read_version(code_file: str, h8: str) -> str | None:
|
||||
"""读某版本全文。版本号非法(路径穿越尝试)或不存在返回 None。"""
|
||||
if not _H8.match(h8 or ""):
|
||||
return None
|
||||
path = _version_path(code_file, h8)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
@@ -96,11 +96,13 @@ _KINDS = ("backtest", "replay")
|
||||
|
||||
|
||||
def update_instance_run(inst_id: int, kind: str, status: str,
|
||||
ret: float | None = None) -> bool:
|
||||
ret: float | None = None,
|
||||
meta: dict | None = None) -> bool:
|
||||
"""事件型运行(回测/回放)完成/失败时回写档案。
|
||||
|
||||
kind: backtest/replay(paper_live/live 是持续运行,读时聚合不落盘)。
|
||||
ret: 小数收益(0.1548=+15.48%),None 则保留旧值。
|
||||
meta: 附加信息(如 {"code_hash": ...} 发起时代码版本)。
|
||||
返回 False = 实例不存在(如已删,静默丢弃)。
|
||||
"""
|
||||
if kind not in _KINDS:
|
||||
@@ -114,6 +116,10 @@ def update_instance_run(inst_id: int, kind: str, status: str,
|
||||
r["last_return"] = ret
|
||||
r.setdefault("run_returns", {})
|
||||
r["run_returns"][kind] = ret
|
||||
if meta:
|
||||
r.setdefault("run_meta", {})
|
||||
r["run_meta"].setdefault(kind, {})
|
||||
r["run_meta"][kind].update(meta)
|
||||
r["updated_at"] = time.strftime("%Y-%m-%d")
|
||||
_save(rows)
|
||||
return True
|
||||
|
||||
@@ -100,6 +100,15 @@ async def submit_cta(req: CtaBacktestRequest):
|
||||
cls = get_strategy_class(req.strategy)
|
||||
if cls is None:
|
||||
raise HTTPException(status_code=400, detail=f"未知策略: {req.strategy}")
|
||||
# §12.6 补:带档案发起 → 当时代码快照(完成回写 run_meta.code_hash)
|
||||
code_hash = None
|
||||
if req.instance_id:
|
||||
from .instance_store import get_instance_params_snapshot
|
||||
from .code_versions import snapshot_code
|
||||
|
||||
cf = (get_instance_params_snapshot(req.instance_id) or {}).get("code_file") or ""
|
||||
snap = snapshot_code(cf)
|
||||
code_hash = snap["code_hash"] if snap else None
|
||||
tid = await get_orchestrator().submit_cta(
|
||||
strategy_class=cls,
|
||||
symbol=req.symbol,
|
||||
@@ -112,6 +121,7 @@ async def submit_cta(req: CtaBacktestRequest):
|
||||
position_pct=req.position_pct,
|
||||
interval=req.interval,
|
||||
instance_id=req.instance_id,
|
||||
code_hash=code_hash,
|
||||
)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@@ -107,7 +107,13 @@ def create_live(req: LiveCreateRequest):
|
||||
else:
|
||||
# D1 发起时快照:绑已有档案 → setting 用档案当时的参数(API 直调也不绕过)
|
||||
req.setting = dict(instance_store.get_instance_params_snapshot(req.instance_id).get("params") or {})
|
||||
# §12.6 补:发起时代码版本快照
|
||||
from .code_versions import snapshot_code
|
||||
|
||||
inst_snap = instance_store.get_instance_params_snapshot(req.instance_id) or {}
|
||||
code_snap = snapshot_code(inst_snap.get("code_file") or "")
|
||||
payload = req.model_dump()
|
||||
payload["code_hash"] = code_snap["code_hash"] if code_snap else None
|
||||
if payload.get("strategy_type") == "portfolio":
|
||||
# 组合实盘:vt_symbol 占位为池名;setting 存组合参数(supervisor 转发 env)
|
||||
if not payload.get("strategy_class"):
|
||||
|
||||
@@ -115,6 +115,11 @@ def create_paper(req: PaperCreateRequest):
|
||||
snap = instance_store.get_instance_params_snapshot(req.instance_id) if req.instance_id else None
|
||||
if snap and req.strategies:
|
||||
req.strategies[0].params = dict(snap.get("params") or {})
|
||||
# §12.6 补:发起时代码版本快照(运行可回溯当时跑的哪版代码)
|
||||
from .code_versions import snapshot_code
|
||||
|
||||
code_snap = snapshot_code((snap or {}).get("code_file") or "")
|
||||
code_hash = code_snap["code_hash"] if code_snap else None
|
||||
# 实走/影子是开放账户:起止日期无意义,开始=创建当天(组合日终重放依赖 start_date,
|
||||
# 空值会崩),结束留空;仅回放保留用户填的历史区间
|
||||
if req.mode in ("live", "shadow"):
|
||||
@@ -129,6 +134,7 @@ def create_paper(req: PaperCreateRequest):
|
||||
if req.mode not in ("live", "shadow"):
|
||||
raise HTTPException(400, "组合策略模拟盘仅支持实走(live)/影子(shadow)模式;历史回放请用「组合回测」")
|
||||
payload = req.model_dump()
|
||||
payload["code_hash"] = code_hash
|
||||
payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay"
|
||||
payload["symbols"] = [req.pool]
|
||||
payload["strategies"] = [{
|
||||
@@ -141,6 +147,7 @@ def create_paper(req: PaperCreateRequest):
|
||||
return {"account_id": aid, "status": "running"}
|
||||
|
||||
cta_payload = req.model_dump()
|
||||
cta_payload["code_hash"] = code_hash
|
||||
cta_payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay"
|
||||
aid = save_account(db, cta_payload)
|
||||
status = "created"
|
||||
@@ -262,7 +269,16 @@ def list_reconcile_pairs(date: str | None = None):
|
||||
report = saved or build_reconcile_report(
|
||||
db, pair["live_account_id"], pair["shadow_account_id"], date)
|
||||
save_reconcile_report(db, report)
|
||||
out.append({**pair, "report": report})
|
||||
# §12.6 补:双轨代码版本一致性(对账 FAIL 先查这行——两边代码不同价差必然大)
|
||||
with sqlite3.connect(db) as conn:
|
||||
hashes = dict(conn.execute(
|
||||
"SELECT id, code_hash FROM paper_accounts WHERE id IN (?,?)",
|
||||
(pair["live_account_id"], pair["shadow_account_id"]),
|
||||
).fetchall())
|
||||
h1 = hashes.get(pair["live_account_id"])
|
||||
h2 = hashes.get(pair["shadow_account_id"])
|
||||
code_match = None if not (h1 and h2) else h1 == h2
|
||||
out.append({**pair, "code_match": code_match, "report": report})
|
||||
return {"pairs": out}
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,15 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest):
|
||||
if req.interval != "d":
|
||||
raise HTTPException(400, "组合回放暂仅支持日线(影子柜台将支持全周期分钟档)")
|
||||
validate_portfolio_request(req) # 400 中文提示(格式/未来/区间/资金/费率),拦在进队列前
|
||||
# §12.6 补:带档案发起 → 当时代码快照(完成回写 run_meta.code_hash)
|
||||
code_hash = None
|
||||
if req.instance_id:
|
||||
from .instance_store import get_instance_params_snapshot
|
||||
from .code_versions import snapshot_code
|
||||
|
||||
cf = (get_instance_params_snapshot(req.instance_id) or {}).get("code_file") or ""
|
||||
snap = snapshot_code(cf)
|
||||
code_hash = snap["code_hash"] if snap else None
|
||||
tid = await get_orchestrator().submit_portfolio(
|
||||
start=req.start_date,
|
||||
end=req.end_date,
|
||||
@@ -68,6 +77,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest):
|
||||
slippage=req.slippage,
|
||||
interval=req.interval,
|
||||
instance_id=req.instance_id,
|
||||
code_hash=code_hash,
|
||||
)
|
||||
return {"task_id": tid}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ def get_instances_enriched():
|
||||
rt = _instance_runtime()
|
||||
out = []
|
||||
for inst in instance_store.list_instances()["instances"]:
|
||||
ent = rt.get(inst["id"], {"running_accounts": [], "drift": False})
|
||||
ent = rt.get(inst["id"], {"running_accounts": [], "drift": False, "code_changed": False})
|
||||
paper_runs = [a for a in ent["running_accounts"] if a["kind"] in ("paper", "shadow")]
|
||||
live_runs = [a for a in ent["running_accounts"] if a["kind"] == "live"]
|
||||
status = dict(inst.get("status") or {})
|
||||
@@ -103,6 +103,7 @@ def get_instances_enriched():
|
||||
"status": status,
|
||||
"running_accounts": ent["running_accounts"],
|
||||
"drift": ent["drift"],
|
||||
"code_changed": ent["code_changed"],
|
||||
})
|
||||
return {"instances": out}
|
||||
|
||||
@@ -134,17 +135,40 @@ def del_instance(inst_id: int):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== §12.6 补:代码版本快照查询 =====
|
||||
|
||||
@router.get("/strategy/code-versions")
|
||||
def get_code_versions(file: str):
|
||||
"""该策略文件的全部发起快照(新→旧)。"""
|
||||
from .code_versions import list_versions
|
||||
|
||||
return {"file": file, "versions": list_versions(file)}
|
||||
|
||||
|
||||
@router.get("/strategy/code-versions/{file}/{h8}")
|
||||
def get_code_version(file: str, h8: str):
|
||||
"""读某版本全文(编辑器 diff 视图左栏)。"""
|
||||
from .code_versions import read_version
|
||||
|
||||
code = read_version(file, h8)
|
||||
if code is None:
|
||||
raise HTTPException(status_code=404, detail="版本不存在")
|
||||
return {"file": file, "code_version": h8, "code": code}
|
||||
|
||||
|
||||
# ===== §12.6 做实:读时聚合(持续型运行)+ P1 实例全景 =====
|
||||
|
||||
def _instance_runtime() -> dict[int, dict]:
|
||||
"""读时聚合每个档案的模拟/实盘账户(持续型运行的"回写")。
|
||||
|
||||
返回 {instance_id: {running_accounts: [...], drift: bool}};DB 不可达返回 {}
|
||||
(策略库退化为纯 JSON 状态展示,不阻塞)。
|
||||
返回 {instance_id: {running_accounts: [...], drift: bool, code_changed: bool}};
|
||||
DB 不可达返回 {}(策略库退化为纯 JSON 状态展示,不阻塞)。
|
||||
"""
|
||||
import json as _json
|
||||
import sqlite3
|
||||
|
||||
from .code_versions import code_changed as _code_changed
|
||||
|
||||
out: dict[int, dict] = {}
|
||||
try:
|
||||
from .routes_paper import _db_path as paper_db
|
||||
@@ -155,12 +179,12 @@ def _instance_runtime() -> dict[int, dict]:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, mode, engine, status, strategies, instance_id, "
|
||||
"initial_capital FROM paper_accounts WHERE instance_id IS NOT NULL"
|
||||
"initial_capital, code_hash FROM paper_accounts WHERE instance_id IS NOT NULL"
|
||||
).fetchall()
|
||||
from sanguo_trader.persistence import load_last_balance
|
||||
|
||||
for r in rows:
|
||||
ent = out.setdefault(r["instance_id"], {"running_accounts": [], "drift": False})
|
||||
ent = out.setdefault(r["instance_id"], {"running_accounts": [], "drift": False, "code_changed": False})
|
||||
ret = None
|
||||
last = load_last_balance(pdb, r["id"])
|
||||
if last and r["initial_capital"]:
|
||||
@@ -169,6 +193,9 @@ def _instance_runtime() -> dict[int, dict]:
|
||||
drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(r)))
|
||||
if drifted:
|
||||
ent["drift"] = True
|
||||
chg = _code_changed((snapshot or {}).get("code_file") or "", r["code_hash"])
|
||||
if chg:
|
||||
ent["code_changed"] = True
|
||||
if r["status"] == "running":
|
||||
ent["running_accounts"].append({
|
||||
"kind": "shadow" if r["engine"] == "shadow" else "paper",
|
||||
@@ -176,6 +203,8 @@ def _instance_runtime() -> dict[int, dict]:
|
||||
"label": r["name"] or f"paper#{r['id']}",
|
||||
"ret": ret,
|
||||
"drifted": drifted,
|
||||
"code_changed": chg,
|
||||
"code_version": (r["code_hash"] or "")[:8] or None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -189,7 +218,7 @@ def _instance_runtime() -> dict[int, dict]:
|
||||
iid = a.get("instance_id")
|
||||
if not iid:
|
||||
continue
|
||||
ent = out.setdefault(iid, {"running_accounts": [], "drift": False})
|
||||
ent = out.setdefault(iid, {"running_accounts": [], "drift": False, "code_changed": False})
|
||||
ret = None
|
||||
first, last = get_first_balance(ldb, a["id"]), get_last_balance(ldb, a["id"])
|
||||
if first and last and first.get("total"):
|
||||
@@ -199,6 +228,9 @@ def _instance_runtime() -> dict[int, dict]:
|
||||
drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(a)))
|
||||
if drifted:
|
||||
ent["drift"] = True
|
||||
chg = _code_changed((snapshot or {}).get("code_file") or "", a.get("code_hash"))
|
||||
if chg:
|
||||
ent["code_changed"] = True
|
||||
if a.get("status") == "running":
|
||||
ent["running_accounts"].append({
|
||||
"kind": "live",
|
||||
@@ -206,6 +238,8 @@ def _instance_runtime() -> dict[int, dict]:
|
||||
"label": a.get("name") or f"live#{a['id']}",
|
||||
"ret": ret,
|
||||
"drifted": drifted,
|
||||
"code_changed": chg,
|
||||
"code_version": (a.get("code_hash") or "")[:8] or None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -217,6 +251,8 @@ def get_instance_overview(inst_id: int):
|
||||
"""P1 实例全景:档案 + 全部运行账户(含净值尾部)+ 合并持仓归因。"""
|
||||
import sqlite3
|
||||
|
||||
from .code_versions import code_changed as _code_changed
|
||||
|
||||
snap = instance_store.get_instance_params_snapshot(inst_id)
|
||||
if snap is None:
|
||||
raise HTTPException(status_code=404, detail="实例不存在")
|
||||
@@ -256,6 +292,8 @@ def get_instance_overview(inst_id: int):
|
||||
"kind": "shadow" if r["engine"] == "shadow" else "paper",
|
||||
"aid": r["id"], "label": r["name"], "mode": r["mode"],
|
||||
"status": r["status"], "ret": ret,
|
||||
"code_version": (r["code_hash"] or "")[:8] or None,
|
||||
"code_changed": _code_changed(snap.get("code_file") or "", r["code_hash"]),
|
||||
"equity": [{"date": c[0], "equity": c[1]} for c in reversed(curve)],
|
||||
})
|
||||
if r["status"] == "running":
|
||||
@@ -284,6 +322,8 @@ def get_instance_overview(inst_id: int):
|
||||
runs.append({
|
||||
"kind": "live", "aid": a["id"], "label": a.get("name"),
|
||||
"status": a.get("status"), "ret": ret, "equity": [],
|
||||
"code_version": (a.get("code_hash") or "")[:8] or None,
|
||||
"code_changed": _code_changed(snap.get("code_file") or "", a.get("code_hash")),
|
||||
})
|
||||
if a.get("status") == "running":
|
||||
for p in load_positions(ldb, a["id"]):
|
||||
|
||||
@@ -89,6 +89,7 @@ def init_db(db_path: str) -> None:
|
||||
("max_pool", "INTEGER"),
|
||||
("benchmark", "TEXT"),
|
||||
("instance_id", "INTEGER"), # §12.6 实例做实:账户绑档案
|
||||
("code_hash", "TEXT"), # §12.6 补:发起时代码版本快照
|
||||
):
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE live_accounts ADD COLUMN {col} {ddl}")
|
||||
@@ -107,8 +108,8 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
|
||||
(name, account, vt_symbol, strategy_class, strategy_name, setting,
|
||||
status, interval, initial_capital, connect_wait_sec, init_wait_sec,
|
||||
mini_path, created_at, updated_at,
|
||||
strategy_type, pool, max_pool, benchmark, instance_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
strategy_type, pool, max_pool, benchmark, instance_id, code_hash)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
account.get("name", "live"),
|
||||
account.get("account", ""),
|
||||
@@ -128,6 +129,7 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
|
||||
int(account.get("max_pool", 0) or 0),
|
||||
account.get("benchmark", ""),
|
||||
account.get("instance_id"),
|
||||
account.get("code_hash"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -44,7 +44,8 @@ class Orchestrator:
|
||||
async def submit_cta(self, strategy_class, symbol: str, params: dict,
|
||||
start: str, end: str, cfg, benchmark: str = "hs300",
|
||||
capital: float = 1_000_000, position_pct: float = 0.95,
|
||||
interval: str = "d", instance_id: int | None = None) -> str:
|
||||
interval: str = "d", instance_id: int | None = None,
|
||||
code_hash: str | None = None) -> str:
|
||||
"""Submit a CTA backtesting task asynchronously"""
|
||||
# Stable uuid up front → reused as the persisted DB task_id, so runner-id ==
|
||||
# DB task_id (durable across restarts; previously used id(params) memory addr).
|
||||
@@ -63,6 +64,7 @@ class Orchestrator:
|
||||
position_pct=position_pct,
|
||||
interval=interval,
|
||||
instance_id=instance_id,
|
||||
code_hash=code_hash,
|
||||
)
|
||||
await self._notify_stage(task_id, "排队中")
|
||||
|
||||
@@ -144,7 +146,8 @@ class Orchestrator:
|
||||
min_commission: float = 5.0,
|
||||
slippage: float = 0.0,
|
||||
interval: str = "d",
|
||||
instance_id: int | None = None) -> str:
|
||||
instance_id: int | None = None,
|
||||
code_hash: str | None = None) -> str:
|
||||
"""Submit a portfolio backtest task asynchronously.
|
||||
|
||||
Runs runner_backtest as a subprocess (3600s hard cap) inside the
|
||||
@@ -172,6 +175,7 @@ class Orchestrator:
|
||||
db_path=self.db_path,
|
||||
file_dir=file_dir,
|
||||
instance_id=instance_id,
|
||||
code_hash=code_hash,
|
||||
)
|
||||
await self._notify_stage(task_id, "排队中")
|
||||
|
||||
@@ -217,7 +221,8 @@ class Orchestrator:
|
||||
stats = getattr(result, "statistics", None) or {}
|
||||
m = stats.get("metrics") if isinstance(stats.get("metrics"), dict) else stats
|
||||
ret = m.get("total_return")
|
||||
update_instance_run(int(inst_id), "backtest", status, ret)
|
||||
meta = {"code_hash": spec["code_hash"]} if spec.get("code_hash") else None
|
||||
update_instance_run(int(inst_id), "backtest", status, ret, meta)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
|
||||
@@ -84,6 +84,11 @@ def init_db(db_path: str) -> None:
|
||||
conn.execute("ALTER TABLE paper_accounts ADD COLUMN instance_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass # 列已存在
|
||||
# 迁移:老库补 code_hash 列(§12.6 补:发起时代码版本快照)
|
||||
try:
|
||||
conn.execute("ALTER TABLE paper_accounts ADD COLUMN code_hash TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # 列已存在
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.commit()
|
||||
|
||||
@@ -95,8 +100,8 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
|
||||
(task_id, owner_id, name, strategy_type, mode, interval, symbols, strategies,
|
||||
initial_capital, rate, slippage, size, pricetick,
|
||||
stamp_duty_rate, transfer_fee_rate, min_commission,
|
||||
status, start_date, end_date, engine, instance_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
status, start_date, end_date, engine, instance_id, code_hash, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
account.get("task_id"), account.get("owner_id", "admin"),
|
||||
account.get("name"), account.get("strategy_type", "cta"),
|
||||
@@ -114,6 +119,7 @@ def save_account(db_path: str, account: dict[str, Any]) -> int:
|
||||
account.get("end_date") or account.get("end"),
|
||||
account.get("engine", "eod_replay"),
|
||||
account.get("instance_id"),
|
||||
account.get("code_hash"),
|
||||
_now(), _now(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""§12.6 补:策略代码版本快照(发起时快照/去重/防穿越/代码已变更标记)。"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sanguo_api import code_versions as CV
|
||||
from sanguo_api import instance_store
|
||||
from sanguo_api.app import create_app
|
||||
from sanguo_api.auth import create_token, set_jwt_config
|
||||
from sanguo_api.routes_paper import set_db_path as set_paper_db
|
||||
|
||||
|
||||
def test_snapshot_dedup_and_read(tmp_path, monkeypatch):
|
||||
"""同内容只存一份(内容寻址去重);list/read 往返一致。"""
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
# 造一个假策略文件(monkeypatch registry 读)
|
||||
class _FakeFile:
|
||||
def __init__(self, src):
|
||||
self._src = src
|
||||
def __call__(self, name):
|
||||
return {"name": name, "code": self._src}
|
||||
monkeypatch.setattr("sanguo_api.strategy_registry.read_strategy_file", _FakeFile("print(1)\n"))
|
||||
s1 = CV.snapshot_code("demo.py")
|
||||
s2 = CV.snapshot_code("demo.py")
|
||||
assert s1["code_version"] == s2["code_version"]
|
||||
files = os.listdir(tmp_path / "versions")
|
||||
assert len(files) == 1 # 去重
|
||||
vers = CV.list_versions("demo.py")
|
||||
assert len(vers) == 1 and vers[0]["code_version"] == s1["code_version"]
|
||||
assert CV.read_version("demo.py", s1["code_version"]) == "print(1)\n"
|
||||
|
||||
|
||||
def test_read_version_rejects_traversal(tmp_path, monkeypatch):
|
||||
"""版本号只认 8 位 hex:路径穿越串直接拒绝。"""
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
assert CV.read_version("demo.py", "../../etc/passwd") is None
|
||||
assert CV.read_version("demo.py", "ZZZZZZZZ") is None
|
||||
|
||||
|
||||
def test_code_changed_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
class _FakeFile:
|
||||
def __init__(self, src):
|
||||
self._src = src
|
||||
def __call__(self, name):
|
||||
return {"name": name, "code": self._src}
|
||||
fake = _FakeFile("v1")
|
||||
monkeypatch.setattr("sanguo_api.strategy_registry.read_strategy_file", fake)
|
||||
snap = CV.snapshot_code("demo.py")
|
||||
assert CV.code_changed("demo.py", snap["code_hash"]) is False
|
||||
fake._src = "v2-changed" # 文件改了
|
||||
assert CV.code_changed("demo.py", snap["code_hash"]) is True
|
||||
assert CV.code_changed("demo.py", None) is None # 早期账户无哈希=不可判
|
||||
|
||||
|
||||
def _client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(instance_store, "_STORE_PATH", str(tmp_path / "inst.json"))
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
set_jwt_config(secret="t", expire_minutes=60)
|
||||
pdb = os.path.join(str(tmp_path), "p.db")
|
||||
app = create_app(db_path=pdb)
|
||||
set_paper_db(pdb)
|
||||
return TestClient(app), create_token("admin"), pdb
|
||||
|
||||
|
||||
def test_paper_create_stores_code_hash(tmp_path, monkeypatch):
|
||||
"""发起模拟盘 → 账户落 code_hash + 快照文件存在(code_file 能解析到真实策略时)。"""
|
||||
c, token, pdb = _client(tmp_path, monkeypatch)
|
||||
r = c.post("/api/v1/paper/create", json={
|
||||
"mode": "live",
|
||||
"symbols": ["600000"],
|
||||
"strategies": [{"name": "AShareDoubleMaStrategy", "symbol": "600000",
|
||||
"params": {"fast_window": 5}}],
|
||||
"start": "2024-01-01", "end": "",
|
||||
}, headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
h = sqlite3.connect(pdb).execute(
|
||||
"SELECT code_hash FROM paper_accounts WHERE id=1").fetchone()[0]
|
||||
# 类名解析到 double_ma.py → 快照成功有哈希;解析不到也是 None 不崩
|
||||
if h:
|
||||
assert len(h) == 32
|
||||
assert any(f.startswith("double_ma.py.") for f in os.listdir(tmp_path / "versions"))
|
||||
|
||||
|
||||
def test_enriched_code_changed_flag(tmp_path, monkeypatch):
|
||||
"""文件后续被改 → enriched 亮 code_changed。"""
|
||||
c, token, pdb = _client(tmp_path, monkeypatch)
|
||||
c.post("/api/v1/paper/create", json={
|
||||
"mode": "live",
|
||||
"symbols": ["600000"],
|
||||
"strategies": [{"name": "AShareDoubleMaStrategy", "symbol": "600000",
|
||||
"params": {}}],
|
||||
"start": "2024-01-01", "end": "",
|
||||
}, headers={"Authorization": f"Bearer {token}"})
|
||||
insts = c.get("/api/v1/strategy/instances/enriched").json()["instances"]
|
||||
assert insts[0]["code_changed"] in (False, None) # 刚发起必然一致/不可判
|
||||
# 改当前文件哈希(模拟代码变更)
|
||||
monkeypatch.setattr(CV, "current_hash", lambda f: "deadbeef")
|
||||
insts = c.get("/api/v1/strategy/instances/enriched").json()["instances"]
|
||||
me = insts[0]
|
||||
if me["running_accounts"]:
|
||||
acc = me["running_accounts"][0]
|
||||
if acc.get("code_version"): # 有哈希的账户才可判
|
||||
assert me["code_changed"] is True
|
||||
|
||||
|
||||
def test_code_version_endpoints(tmp_path, monkeypatch):
|
||||
"""GET /strategy/code-versions 列表 + 单版本全文。"""
|
||||
c, token, _ = _client(tmp_path, monkeypatch)
|
||||
class _FakeFile:
|
||||
def __call__(self, name):
|
||||
return {"name": name, "code": "x = 1\n"}
|
||||
monkeypatch.setattr("sanguo_api.strategy_registry.read_strategy_file", _FakeFile())
|
||||
snap = CV.snapshot_code("demo.py")
|
||||
lst = c.get("/api/v1/strategy/code-versions?file=demo.py").json()
|
||||
assert lst["versions"] and lst["versions"][0]["code_version"] == snap["code_version"]
|
||||
one = c.get(f"/api/v1/strategy/code-versions/demo.py/{snap['code_version']}").json()
|
||||
assert one["code"] == "x = 1\n"
|
||||
assert c.get("/api/v1/strategy/code-versions/demo.py/00000000").status_code == 404
|
||||
Reference in New Issue
Block a user