新增合约全历史拉取与 K 线打分叠加功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ class RunRangeRequest(BaseModel):
|
||||
end_date: str
|
||||
|
||||
|
||||
class RunFullRequest(BaseModel):
|
||||
ts_code: str
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
ts_code: str
|
||||
trade_date: str
|
||||
@@ -131,6 +135,37 @@ def run_range(req: RunRangeRequest):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/run/full")
|
||||
def run_full(req: RunFullRequest):
|
||||
"""拉取指定合约全部历史数据,保存 candles,对所有可打分日期逐日打分并保存。"""
|
||||
df = fetcher.fetch_contract(req.ts_code)
|
||||
storage.save_candles(df)
|
||||
|
||||
results, warnings, total_days, scored_count = scorer.score_all(df)
|
||||
|
||||
for r in results:
|
||||
storage.save_score(r)
|
||||
|
||||
skipped_count = total_days - scored_count
|
||||
|
||||
return {
|
||||
"ts_code": req.ts_code,
|
||||
"total_days": total_days,
|
||||
"scored_count": scored_count,
|
||||
"skipped_count": skipped_count,
|
||||
"warnings": warnings,
|
||||
"results": [
|
||||
{
|
||||
"trade_date": r.trade_date,
|
||||
"close": r.close,
|
||||
"composite": r.composite,
|
||||
"signal": r.signal,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/scores")
|
||||
def list_scores(
|
||||
ts_code: Optional[str] = Query(None),
|
||||
|
||||
@@ -254,3 +254,29 @@ def score_range(
|
||||
warnings.append(str(e))
|
||||
|
||||
return results, warnings
|
||||
|
||||
|
||||
def score_all(df: pd.DataFrame) -> tuple[list[ScoreResult], list[str], int, int]:
|
||||
"""对 DataFrame 中所有有足够前置数据的交易日逐日打分。
|
||||
|
||||
Returns:
|
||||
(results, warnings, total_days, scored_count)
|
||||
"""
|
||||
if len(df) < 31:
|
||||
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
|
||||
|
||||
results: list[ScoreResult] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for idx in range(30, len(df)):
|
||||
subset = df.iloc[: idx + 1].copy()
|
||||
trade_date = str(df.iloc[idx]["trade_date"])
|
||||
try:
|
||||
result = score_daily(subset)
|
||||
results.append(result)
|
||||
except ValueError as e:
|
||||
warnings.append(f"{trade_date}: {e}")
|
||||
|
||||
total_days = len(df) - 30
|
||||
scored_count = len(results)
|
||||
return results, warnings, total_days, scored_count
|
||||
|
||||
@@ -88,6 +88,40 @@ func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
type runFullRequest struct {
|
||||
TsCode string `json:"ts_code"`
|
||||
}
|
||||
|
||||
func (d *Deps) RunFull(w http.ResponseWriter, r *http.Request) {
|
||||
var req runFullRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.TsCode == "" {
|
||||
writeErr(w, http.StatusBadRequest, "ts_code is required")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "encode request failed")
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 300 * time.Second}
|
||||
resp, err := client.Post(d.TushareURL+"/api/v1/run/full", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" {
|
||||
|
||||
@@ -33,6 +33,7 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
|
||||
r.Post("/run", d.RunPipeline)
|
||||
r.Post("/run/batch", d.RunBatch)
|
||||
r.Post("/run/range", d.RunRange)
|
||||
r.Post("/run/full", d.RunFull)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAdmin)
|
||||
|
||||
@@ -75,7 +75,7 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
|
||||
args = append(args, "%"+f.Signal+"%")
|
||||
}
|
||||
q += " ORDER BY trade_date DESC, id DESC"
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
if f.Limit <= 0 || f.Limit > 1000 {
|
||||
f.Limit = 200
|
||||
}
|
||||
q += " LIMIT " + next()
|
||||
|
||||
@@ -53,10 +53,34 @@ export interface RunRangeResponse {
|
||||
results: RunRangeResult[]
|
||||
}
|
||||
|
||||
export interface RunFullRequest {
|
||||
ts_code: string
|
||||
}
|
||||
|
||||
export interface RunFullResult {
|
||||
trade_date: string
|
||||
close: number
|
||||
composite: number
|
||||
signal: string
|
||||
}
|
||||
|
||||
export interface RunFullResponse {
|
||||
ts_code: string
|
||||
total_days: number
|
||||
scored_count: number
|
||||
skipped_count: number
|
||||
warnings: string[]
|
||||
results: RunFullResult[]
|
||||
}
|
||||
|
||||
export function runRange(req: RunRangeRequest) {
|
||||
return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function runFull(req: RunFullRequest) {
|
||||
return client.post<RunFullResponse>('/run/full', req, { timeout: 300_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
export function runBatch() {
|
||||
return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Candle } from '@/api/candles'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { useMobile } from '@/composables/useMobile'
|
||||
|
||||
const props = defineProps<{ data: Candle[] }>()
|
||||
const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
|
||||
const theme = useThemeStore()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -28,51 +28,86 @@ function render() {
|
||||
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
|
||||
const oi = props.data.map((c) => c.oi)
|
||||
|
||||
const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
|
||||
const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
|
||||
|
||||
const hasScores = props.scores && props.scores.length > 0
|
||||
const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
|
||||
const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
|
||||
const grids: any[] = [
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
|
||||
]
|
||||
const xAxes: any[] = [
|
||||
{ type: 'category', data: dates, scale: true, boundaryGap: false },
|
||||
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
|
||||
]
|
||||
const yAxes: any[] = [
|
||||
{ scale: true, splitArea: { show: true } },
|
||||
{ gridIndex: 1, scale: true, splitNumber: 3 },
|
||||
]
|
||||
const series: any[] = [
|
||||
{
|
||||
name: 'K 线',
|
||||
type: 'candlestick',
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: '#ec3a3a',
|
||||
color0: '#26a69a',
|
||||
borderColor: '#ec3a3a',
|
||||
borderColor0: '#26a69a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '持仓量',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: oi,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#5470c6' },
|
||||
areaStyle: { opacity: 0.15, color: '#5470c6' },
|
||||
},
|
||||
]
|
||||
|
||||
if (hasScores) {
|
||||
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
|
||||
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
|
||||
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2 })
|
||||
series.push({
|
||||
name: '综合分',
|
||||
type: 'bar',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: compositeData,
|
||||
barWidth: '60%',
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const val = params.value as number | null
|
||||
if (val == null) return 'transparent'
|
||||
if (val >= 80) return '#ec3a3a'
|
||||
if (val >= 50) return '#f89898'
|
||||
if (val >= 40) return '#89d6c7'
|
||||
return '#26a69a'
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
chart.setOption(
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['K 线', '持仓量'], top: 0 },
|
||||
grid: [
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: '60%' },
|
||||
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '78%', height: '18%' },
|
||||
],
|
||||
xAxis: [
|
||||
{ type: 'category', data: dates, scale: true, boundaryGap: false },
|
||||
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
|
||||
],
|
||||
yAxis: [
|
||||
{ scale: true, splitArea: { show: true } },
|
||||
{ gridIndex: 1, scale: true, splitNumber: 3 },
|
||||
],
|
||||
legend: { data: legendData, top: 0 },
|
||||
grid: grids,
|
||||
xAxis: xAxes,
|
||||
yAxis: yAxes,
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1] },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K 线',
|
||||
type: 'candlestick',
|
||||
data: ohlc,
|
||||
itemStyle: {
|
||||
color: '#ec3a3a',
|
||||
color0: '#26a69a',
|
||||
borderColor: '#ec3a3a',
|
||||
borderColor0: '#26a69a',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '持仓量',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: oi,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#5470c6' },
|
||||
areaStyle: { opacity: 0.15, color: '#5470c6' },
|
||||
},
|
||||
{ type: 'inside', xAxisIndex: xAxisIndices },
|
||||
{ type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
|
||||
],
|
||||
series,
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { listContracts } from '@/api/scores'
|
||||
import { listCandles, type Candle } from '@/api/candles'
|
||||
import { listScores, type Score } from '@/api/scores'
|
||||
import { runFull, type RunFullResponse } from '@/api/run'
|
||||
import KLineChart from '@/components/KLineChart.vue'
|
||||
import { useMobile } from '@/composables/useMobile'
|
||||
|
||||
@@ -15,7 +17,10 @@ const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
|
||||
|
||||
const contracts = ref<string[]>([])
|
||||
const candles = ref<Candle[]>([])
|
||||
const scores = ref<Score[]>([])
|
||||
const loading = ref(false)
|
||||
const fullLoading = ref(false)
|
||||
const fullResult = ref<RunFullResponse | null>(null)
|
||||
|
||||
async function reload() {
|
||||
if (!filter.ts_code) {
|
||||
@@ -25,12 +30,48 @@ async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [start, end] = filter.range || []
|
||||
candles.value = await listCandles(filter.ts_code, start, end)
|
||||
const [candleData, scoreData] = await Promise.all([
|
||||
listCandles(filter.ts_code, start, end),
|
||||
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
|
||||
])
|
||||
candles.value = candleData
|
||||
scores.value = scoreData
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchAndScore() {
|
||||
if (!filter.ts_code) {
|
||||
ElMessage.warning('请输入或选择合约')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`即将拉取 ${filter.ts_code} 的全部历史数据并逐日打分,这可能需要一些时间。`,
|
||||
'拉取并打分',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
fullLoading.value = true
|
||||
fullResult.value = null
|
||||
try {
|
||||
const resp = await runFull({ ts_code: filter.ts_code })
|
||||
fullResult.value = resp
|
||||
ElMessage.success(`完成: ${resp.scored_count} 天已打分, ${resp.skipped_count} 天跳过`)
|
||||
await reload()
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || err.message || '请求失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
fullLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
contracts.value = await listContracts().catch(() => [])
|
||||
if (contracts.value.length > 0) {
|
||||
@@ -45,14 +86,22 @@ onMounted(async () => {
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="!isMobile">
|
||||
<el-form-item label="合约">
|
||||
<el-select
|
||||
v-model="filter.ts_code"
|
||||
placeholder="选择合约"
|
||||
filterable
|
||||
:style="{ width: isMobile ? '100%' : '200px' }"
|
||||
>
|
||||
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-input
|
||||
v-model="filter.ts_code"
|
||||
placeholder="输入合约代码如 FG2509.ZCE"
|
||||
clearable
|
||||
:style="{ width: isMobile ? '100%' : '200px' }"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filter.ts_code"
|
||||
placeholder="已存合约"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker
|
||||
@@ -67,12 +116,27 @@ onMounted(async () => {
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="reload">刷新</el-button>
|
||||
<el-button type="warning" :loading="fullLoading" @click="handleFetchAndScore">
|
||||
拉取并打分
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="fullResult" shadow="never" class="result-card">
|
||||
<el-descriptions :column="isMobile ? 2 : 4" border>
|
||||
<el-descriptions-item label="合约">{{ fullResult.ts_code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总天数">{{ fullResult.total_days }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已打分">{{ fullResult.scored_count }}</el-descriptions-item>
|
||||
<el-descriptions-item label="跳过">{{ fullResult.skipped_count }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="chart-card" v-loading="loading">
|
||||
<KLineChart :data="candles" />
|
||||
<KLineChart
|
||||
:data="candles"
|
||||
:scores="scores.map((s) => ({ trade_date: s.trade_date, composite: s.composite }))"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,6 +150,9 @@ onMounted(async () => {
|
||||
.filter-card :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.result-card :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.chart-card :deep(.el-card__body) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user