功能更新
This commit is contained in:
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import type { BacktestRequest } from '@/api/types';
|
||||
|
||||
defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{ run: [req: BacktestRequest] }>();
|
||||
|
||||
interface ParamDef { k: string; label: string; def: number; }
|
||||
const STRATS: { id: string; label: string; params: ParamDef[] }[] = [
|
||||
{ id: 'ma_cross', label: '双均线交叉', params: [{ k: 'fast', label: '快均线', def: 5 }, { k: 'slow', label: '慢均线', def: 20 }] },
|
||||
{ id: 'single_ma', label: '单均线(价格穿越)', params: [{ k: 'period', label: '均线周期', def: 20 }] },
|
||||
{ id: 'macd_cross', label: 'MACD 金叉死叉', params: [{ k: 'fast', label: '快线', def: 12 }, { k: 'slow', label: '慢线', def: 26 }, { k: 'signal', label: '信号线', def: 9 }] },
|
||||
];
|
||||
const TF_OPTIONS = [
|
||||
{ label: '日线', value: '1d' },
|
||||
{ label: '周线', value: '1w' },
|
||||
{ label: '月线', value: '1M' },
|
||||
{ label: '年线', value: '1y' },
|
||||
];
|
||||
const QUICK = [
|
||||
{ code: '000001', name: '平安银行' },
|
||||
{ code: '600519', name: '贵州茅台' },
|
||||
{ code: '000858', name: '五粮液' },
|
||||
{ code: '601318', name: '中国平安' },
|
||||
{ code: 'DEMO', name: '合成数据' },
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
symbol: '000001',
|
||||
timeframe: '1d',
|
||||
strategy: 'ma_cross',
|
||||
params: {} as Record<string, number>,
|
||||
initial_cash: 1000000,
|
||||
fast_mode: false,
|
||||
});
|
||||
|
||||
function applyDefaults(stratId: string) {
|
||||
const s = STRATS.find((x) => x.id === stratId)!;
|
||||
form.params = Object.fromEntries(s.params.map((p) => [p.k, p.def]));
|
||||
}
|
||||
watch(() => form.strategy, (id) => applyDefaults(id));
|
||||
applyDefaults(form.strategy);
|
||||
|
||||
const currentParams = computed(() => STRATS.find((x) => x.id === form.strategy)!.params);
|
||||
|
||||
function onRun() {
|
||||
emit('run', {
|
||||
symbol: form.symbol,
|
||||
timeframe: form.timeframe,
|
||||
strategy: form.strategy,
|
||||
params: { ...form.params },
|
||||
initial_cash: form.initial_cash,
|
||||
fast_mode: form.fast_mode,
|
||||
} satisfies BacktestRequest);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<label class="lbl">策略</label>
|
||||
<select v-model="form.strategy" class="ipt w-40">
|
||||
<option v-for="s in STRATS" :key="s.id" :value="s.id">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-for="p in currentParams" :key="p.k">
|
||||
<label class="lbl">{{ p.label }}</label>
|
||||
<input v-model.number="form.params[p.k]" type="number" min="1" max="250" class="ipt w-[76px]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">周期</label>
|
||||
<select v-model="form.timeframe" class="ipt w-24">
|
||||
<option v-for="t in TF_OPTIONS" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">标的</label>
|
||||
<input v-model="form.symbol" type="text" class="ipt w-[110px]" placeholder="如 000001" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="lbl">初始资金(元)</label>
|
||||
<input v-model.number="form.initial_cash" type="number" min="1000" step="100000" class="ipt w-[140px]" />
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer select-none items-center gap-2 pb-1.5 text-sm text-slate-600">
|
||||
<input v-model="form.fast_mode" type="checkbox" class="h-4 w-4 rounded border-slate-300 accent-blue-600" />
|
||||
fast 模式
|
||||
</label>
|
||||
|
||||
<div class="ml-auto">
|
||||
<button type="button" class="btn-primary" :disabled="loading" @click="onRun">
|
||||
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||||
{{ loading ? '回测中…' : '开始回测' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span class="mr-1 text-xs text-slate-400">快捷:</span>
|
||||
<button
|
||||
v-for="q in QUICK"
|
||||
:key="q.code"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1 text-xs transition-colors"
|
||||
:class="form.symbol === q.code
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100'"
|
||||
@click="form.symbol = q.code"
|
||||
>
|
||||
{{ q.code }} <span :class="form.symbol === q.code ? 'text-blue-200' : 'text-slate-400'">{{ q.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
策略可选 双均线 / 单均线 / MACD;参数随策略自适应。<code class="rounded border border-slate-200 bg-slate-50 px-1">DEMO</code> 为合成数据,其余为真实 A 股(首次自动经 Tushare 拉取并缓存)。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import type { Candle } from '@/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -9,41 +10,55 @@ const props = defineProps<{
|
||||
indicators: Record<string, Record<string, (number | null)[]>>;
|
||||
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
|
||||
subPanes: string[];
|
||||
/** 主图 MA 周期(可配置,随用户偏好持久化) */
|
||||
maPeriods: number[];
|
||||
/** 各副图高度 px(可配置,随用户偏好持久化) */
|
||||
subHeights: Record<string, number>;
|
||||
/** 主图是否叠加布林带 */
|
||||
showBoll: boolean;
|
||||
/** K线周期标签(仅用于 MA 指标名缓存 key) */
|
||||
timeframe: string;
|
||||
}>();
|
||||
|
||||
// A股语义色(浅色)
|
||||
const UP = '#dc2626';
|
||||
const DOWN = '#16a34a';
|
||||
const C1 = '#2563eb'; // 蓝
|
||||
const C2 = '#f59e0b'; // 橙
|
||||
const C3 = '#a855f7'; // 紫
|
||||
const C4 = '#10b981'; // 绿青
|
||||
// A股语义色(浅色);UP/DOWN 跟随设置中的涨跌配色
|
||||
const settings = useSettingsStore();
|
||||
let UP = '#dc2626';
|
||||
let DOWN = '#16a34a';
|
||||
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981', '#ec4899', '#0ea5e9', '#84cc16', '#f97316'];
|
||||
|
||||
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
|
||||
let PV: Record<string, (number | null)[]> = {};
|
||||
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-ma',
|
||||
shortName: 'MA',
|
||||
figures: [
|
||||
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
|
||||
});
|
||||
// 动态 MA:按周期组合注册一次(figures 的 key 必须静态,故按签名建缓存)
|
||||
const _maReg = new Set<string>();
|
||||
function ensureMaIndicator(periods: number[]) {
|
||||
const sig = [...periods].sort((a, b) => a - b).join('_');
|
||||
if (_maReg.has(sig)) return `pv-ma-${sig}`;
|
||||
registerIndicator({
|
||||
name: `pv-ma-${sig}`,
|
||||
shortName: 'MA',
|
||||
figures: periods.map((p, i) => ({
|
||||
key: `ma${p}`, title: `MA${p}`, type: 'line',
|
||||
styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }),
|
||||
})),
|
||||
calc: (d: KLineData[]) => d.map((_, i) => {
|
||||
const row: Record<string, number | undefined> = {};
|
||||
for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(i);
|
||||
return row;
|
||||
}),
|
||||
});
|
||||
_maReg.add(sig);
|
||||
return `pv-ma-${sig}`;
|
||||
}
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-boll',
|
||||
shortName: 'BOLL',
|
||||
figures: [
|
||||
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
|
||||
});
|
||||
@@ -52,8 +67,8 @@ registerIndicator({
|
||||
name: 'pv-macd',
|
||||
shortName: 'MACD',
|
||||
figures: [
|
||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{
|
||||
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
|
||||
styles: (p) => {
|
||||
@@ -69,9 +84,9 @@ registerIndicator({
|
||||
name: 'pv-kdj',
|
||||
shortName: 'KDJ',
|
||||
figures: [
|
||||
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
|
||||
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#dc2626' }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
|
||||
});
|
||||
@@ -80,63 +95,145 @@ registerIndicator({
|
||||
name: 'pv-rsi',
|
||||
shortName: 'RSI',
|
||||
figures: [
|
||||
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#2563eb' }) },
|
||||
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#f59e0b' }) },
|
||||
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#a855f7' }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
|
||||
});
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let chart: Chart | null = null;
|
||||
let allData: KLineData[] = [];
|
||||
let served = 0; // 已交给图表的 bar 数(从尾部计),backward 分页用
|
||||
|
||||
const LIGHT_STYLES = {
|
||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||
candle: {
|
||||
bar: {
|
||||
upColor: UP, downColor: DOWN,
|
||||
upBorderColor: UP, downBorderColor: DOWN,
|
||||
upWickColor: UP, downWickColor: DOWN,
|
||||
const INIT_BARS = 240; // 初始展示根数(约一年日线)
|
||||
const PAGE_BARS = 500; // 每次向左滚动追加的历史根数
|
||||
|
||||
function lightStyles() {
|
||||
return {
|
||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||
candle: {
|
||||
bar: {
|
||||
upColor: UP, downColor: DOWN,
|
||||
upBorderColor: UP, downBorderColor: DOWN,
|
||||
upWickColor: UP, downWickColor: DOWN,
|
||||
},
|
||||
priceMark: {
|
||||
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
|
||||
last: { upColor: UP, downColor: DOWN },
|
||||
},
|
||||
},
|
||||
priceMark: {
|
||||
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
|
||||
last: { upColor: UP, downColor: DOWN },
|
||||
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
crosshair: {
|
||||
horizontal: { text: { backgroundColor: '#1e293b' } },
|
||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||
},
|
||||
},
|
||||
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
crosshair: {
|
||||
horizontal: { text: { backgroundColor: '#1e293b' } },
|
||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||
},
|
||||
separator: { color: '#e2e8f0' },
|
||||
};
|
||||
separator: { color: '#e2e8f0' },
|
||||
};
|
||||
}
|
||||
|
||||
// 副图默认高度
|
||||
const SUB_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
|
||||
const SUB_DEFAULT_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
|
||||
const subH = (k: string) => Math.max(40, props.subHeights[k] ?? SUB_DEFAULT_HEIGHT[k] ?? 90);
|
||||
|
||||
// ---------- 鼠标跟随信息框(通达信式) ----------
|
||||
interface HoverInfo {
|
||||
date: string; open: number; high: number; low: number; close: number;
|
||||
chg: number | null; amp: number | null; vol: string; amount: string | null;
|
||||
mas: { label: string; value: number | null; color: string }[];
|
||||
}
|
||||
const hover = ref<HoverInfo | null>(null);
|
||||
|
||||
function fmtVol(v: number): string {
|
||||
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
|
||||
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
function bindCrosshair(ch: Chart) {
|
||||
ch.subscribeAction('onCrosshairChange', (payload) => {
|
||||
const k = (payload as { data?: { kLineData?: KLineData } }).data?.kLineData;
|
||||
if (!k || !allData.length) { hover.value = null; return; }
|
||||
// 二分定位索引(全量数组与指标序列按索引对齐)
|
||||
let lo = 0, hi = allData.length - 1, idx = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (allData[mid].timestamp === k.timestamp) { idx = mid; break; }
|
||||
if (allData[mid].timestamp < k.timestamp) lo = mid + 1; else hi = mid - 1;
|
||||
}
|
||||
if (idx < 0) { hover.value = null; return; }
|
||||
const prev = idx > 0 ? allData[idx - 1] : null;
|
||||
const chg = prev ? ((k.close - prev.close) / prev.close) * 100 : null;
|
||||
const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null;
|
||||
hover.value = {
|
||||
date: new Date(k.timestamp).toLocaleDateString('zh-CN'),
|
||||
open: k.open, high: k.high, low: k.low, close: k.close,
|
||||
chg, amp, vol: fmtVol(k.volume ?? 0), amount: null,
|
||||
mas: props.maPeriods.map((p, i) => ({
|
||||
label: `MA${p}`,
|
||||
value: PV[`ma${p}`]?.[idx] ?? null,
|
||||
color: MA_COLORS[i % MA_COLORS.length],
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 画图画线(通达信式工具栏) ----------
|
||||
const TOOLS: { key: string; label: string; title: string }[] = [
|
||||
{ key: '', label: '光标', title: '光标模式(Esc 取消画线)' },
|
||||
{ key: 'segment', label: '╱', title: '线段' },
|
||||
{ key: 'ray', label: '→', title: '射线' },
|
||||
{ key: 'horizontalLine', label: '─', title: '水平线' },
|
||||
{ key: 'rect', label: '▭', title: '矩形' },
|
||||
{ key: 'priceChannelLine', label: '∥', title: '价格通道' },
|
||||
{ key: 'fibLine', label: 'fib', title: '斐波那契回撤' },
|
||||
];
|
||||
const activeTool = ref('');
|
||||
|
||||
function pickTool(key: string) {
|
||||
activeTool.value = key;
|
||||
if (chart && key) chart.createOverlay({ name: key });
|
||||
}
|
||||
|
||||
function clearOverlays() {
|
||||
chart?.removeOverlay();
|
||||
activeTool.value = '';
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
UP = settings.upHex;
|
||||
DOWN = settings.downHex;
|
||||
PV = {};
|
||||
for (const [group, series] of Object.entries(props.indicators)) {
|
||||
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
|
||||
}
|
||||
|
||||
const ch = init(container.value, { styles: LIGHT_STYLES });
|
||||
const ch = init(container.value, { styles: lightStyles() });
|
||||
if (!ch) return;
|
||||
chart = ch;
|
||||
|
||||
const data: KLineData[] = props.candles.map((k) => ({
|
||||
allData = props.candles.map((k) => ({
|
||||
timestamp: new Date(k.ts).getTime(),
|
||||
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
||||
}));
|
||||
served = 0;
|
||||
ch.setDataLoader({
|
||||
getBars: ({ type, callback }) => {
|
||||
if (type === 'update') {
|
||||
const last = data[data.length - 1];
|
||||
callback(last ? [last] : [], { backward: false, forward: false });
|
||||
const last = allData[allData.length - 1];
|
||||
callback(last ? [last] : [], { backward: served < allData.length, forward: false });
|
||||
} else if (type === 'init') {
|
||||
callback(data, { backward: false, forward: false });
|
||||
// 全量已拉到本地:先给最近 INIT_BARS 根,向左滚动时按页吐更早历史
|
||||
served = Math.min(INIT_BARS, allData.length);
|
||||
callback(allData.slice(allData.length - served), { backward: served < allData.length, forward: false });
|
||||
} else if (type === 'backward') {
|
||||
const remain = allData.length - served;
|
||||
const take = Math.min(PAGE_BARS, remain);
|
||||
const start = allData.length - served - take;
|
||||
served += take;
|
||||
callback(allData.slice(start, start + take), { backward: served < allData.length, forward: false });
|
||||
} else {
|
||||
callback([], { backward: false, forward: false });
|
||||
}
|
||||
@@ -146,21 +243,22 @@ function build() {
|
||||
ch.setSymbol({ ticker: props.ticker });
|
||||
ch.setPeriod({ type: 'day', span: 1 });
|
||||
|
||||
// 主图:MA 恒开,BOLL 可选
|
||||
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
|
||||
// 主图:MA(周期可配置)恒开,BOLL 可选
|
||||
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
|
||||
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
|
||||
|
||||
// 副图按用户顺序创建,并压矮;主图吃剩余高度
|
||||
const subHeights = props.subPanes.reduce((s, k) => s + (SUB_HEIGHT[k] ?? 90), 0);
|
||||
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
|
||||
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
|
||||
const total = container.value.clientHeight || 560;
|
||||
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(220, total - subHeights - 24) });
|
||||
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
|
||||
for (const key of props.subPanes) {
|
||||
if (key === 'vol') ch.createIndicator('VOL');
|
||||
else ch.createIndicator(`pv-${key}`);
|
||||
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
|
||||
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
|
||||
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
||||
ch.createIndicator(name);
|
||||
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
|
||||
if (paneId) ch.setPaneOptions({ id: paneId, height: subH(key) });
|
||||
}
|
||||
|
||||
bindCrosshair(ch);
|
||||
ch.setOffsetRightDistance(28);
|
||||
ch.scrollToRealTime();
|
||||
}
|
||||
@@ -168,13 +266,71 @@ function build() {
|
||||
function teardown() {
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
hover.value = null;
|
||||
activeTool.value = '';
|
||||
}
|
||||
|
||||
onMounted(build);
|
||||
onBeforeUnmount(teardown);
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
|
||||
// 涨跌配色切换:重建图表以应用新颜色
|
||||
watch(() => settings.priceTone, () => { teardown(); build(); });
|
||||
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
|
||||
watch(() => props.subHeights, () => {
|
||||
if (!chart) return;
|
||||
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
|
||||
const total = container.value?.clientHeight || 560;
|
||||
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
|
||||
for (const key of props.subPanes) {
|
||||
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
|
||||
const paneId = chart.getIndicators().find((i) => i.name === name)?.paneId;
|
||||
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key) });
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="h-full w-full"></div>
|
||||
<div class="relative h-full w-full">
|
||||
<div ref="container" class="h-full w-full"></div>
|
||||
|
||||
<!-- 鼠标跟随信息框(通达信式小方块) -->
|
||||
<div
|
||||
v-if="hover"
|
||||
class="pointer-events-none absolute left-2 top-2 z-10 rounded border border-slate-700 bg-slate-900/90 px-2.5 py-1.5 font-mono text-[11px] leading-4 text-slate-200 shadow-lg"
|
||||
>
|
||||
<div class="text-slate-400">{{ hover.date }}</div>
|
||||
<div>开 <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.open.toFixed(2) }}</span>
|
||||
高 <span class="text-red-400">{{ hover.high.toFixed(2) }}</span>
|
||||
低 <span class="text-emerald-400">{{ hover.low.toFixed(2) }}</span>
|
||||
收 <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.close.toFixed(2) }}</span></div>
|
||||
<div>幅 <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.chg == null ? '—' : (hover.chg > 0 ? '+' : '') + hover.chg.toFixed(2) + '%' }}</span>
|
||||
振 <span class="text-slate-100">{{ hover.amp == null ? '—' : hover.amp.toFixed(2) + '%' }}</span>
|
||||
量 <span class="text-slate-100">{{ hover.vol }}</span></div>
|
||||
<div v-if="hover.mas.length" class="mt-0.5">
|
||||
<span v-for="(m, i) in hover.mas" :key="m.label" class="mr-2" :style="{ color: m.color }">
|
||||
{{ m.label }} {{ m.value == null ? '—' : m.value.toFixed(2) }}<span v-if="i < hover.mas.length - 1" class="invisible">,</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 画图画线工具栏 -->
|
||||
<div class="absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-md border border-slate-200 bg-white/95 px-1 py-0.5 shadow-sm">
|
||||
<button
|
||||
v-for="t in TOOLS"
|
||||
:key="t.key || 'cursor'"
|
||||
type="button"
|
||||
class="min-w-6 rounded px-1 py-0.5 text-[11px] transition-colors"
|
||||
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900'"
|
||||
:title="t.title"
|
||||
@click="pickTool(t.key)"
|
||||
>{{ t.label }}</button>
|
||||
<span class="mx-0.5 h-3 w-px bg-slate-200"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1 py-0.5 text-[11px] text-red-500 transition-colors hover:bg-red-50"
|
||||
title="清除全部画线"
|
||||
@click="clearOverlays"
|
||||
>清除</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import type { EquityPoint } from '@/api/types';
|
||||
|
||||
const props = defineProps<{ equity: EquityPoint[] }>();
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let chart: echarts.ECharts | null = null;
|
||||
|
||||
const UP = '#dc2626';
|
||||
const DOWN = '#16a34a';
|
||||
|
||||
function buildOption() {
|
||||
const dates = props.equity.map(p => p.ts.slice(0, 10));
|
||||
const vals = props.equity.map(p => Number(p.value.toFixed(2)));
|
||||
const first = vals.length ? vals[0] : 0;
|
||||
const last = vals.length ? vals[vals.length - 1] : 0;
|
||||
const lineColor = last >= first ? UP : DOWN; // A股:盈利红、亏损绿
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 64, right: 18, top: 14, bottom: 26 },
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
backgroundColor: '#ffffff', borderColor: '#e2e8f0', borderWidth: 1,
|
||||
textStyle: { color: '#0f172a' },
|
||||
valueFormatter: (v: number) => (v ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 }),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category', data: dates, boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
axisLabel: { color: '#94a3b8' }, axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', scale: true,
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
axisLabel: { color: '#94a3b8' },
|
||||
},
|
||||
series: [{
|
||||
type: 'line', data: vals, symbol: 'none', smooth: false,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: lineColor + '33' },
|
||||
{ offset: 1, color: lineColor + '00' },
|
||||
]),
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) chart = echarts.init(container.value, undefined, { renderer: 'canvas' });
|
||||
chart?.setOption(buildOption());
|
||||
});
|
||||
onBeforeUnmount(() => { chart?.dispose(); chart = null; });
|
||||
watch(() => props.equity, () => chart?.setOption(buildOption(), true), { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="h-[240px] w-full"></div>
|
||||
</template>
|
||||
@@ -1,319 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { dispose, init, registerIndicator, type Chart, type Crosshair, type KLineData } from 'klinecharts';
|
||||
import type { Candle, IndicatorOut, SignalOut } from '@/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
candles: Candle[];
|
||||
indicators: IndicatorOut;
|
||||
signals: SignalOut[];
|
||||
symbol?: string;
|
||||
timeframe?: string;
|
||||
strategy?: string;
|
||||
}>();
|
||||
|
||||
const TF_LABEL: Record<string, string> = { '1d': '日线', '1w': '周线', '1M': '月线', '1y': '年线' };
|
||||
const STRAT_LABEL: Record<string, string> = { macd_cross: 'MACD', ma_cross: '双均线', single_ma: '单均线' };
|
||||
const WD = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
// A股语义色(浅色主题)
|
||||
const UP = '#dc2626';
|
||||
const DOWN = '#16a34a';
|
||||
const DIF_C = '#2563eb';
|
||||
const DEA_C = '#f59e0b';
|
||||
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981'];
|
||||
|
||||
const IND_LABEL: Record<string, string> = { macd: 'DIF', signal: 'DEA', hist: 'MACD', fast: '快线', slow: '慢线', ma: '均线' };
|
||||
const IND_COLOR: Record<string, string> = {
|
||||
macd: DIF_C, signal: DEA_C, hist: '#94a3b8', fast: DIF_C, slow: DEA_C, ma: '#a855f7',
|
||||
};
|
||||
|
||||
const isMACD = computed(() => props.strategy === 'macd_cross' || 'hist' in (props.indicators.data ?? {}));
|
||||
|
||||
const legendChips = computed(() => {
|
||||
const keys = Object.keys(props.indicators.data ?? {});
|
||||
if (isMACD.value) return [{ label: 'DIF', color: DIF_C }, { label: 'DEA', color: DEA_C }];
|
||||
return keys.map((k, i) => ({ label: IND_LABEL[k] ?? k, color: MA_COLORS[i % MA_COLORS.length] }));
|
||||
});
|
||||
|
||||
// ---------- 后端指标数据注入(单一事实源:不在前端重算指标) ----------
|
||||
// calc 回调按索引回读这些序列,与 K 线严格对齐
|
||||
let BE_SERIES: Record<string, (number | null)[]> = {};
|
||||
|
||||
registerIndicator({
|
||||
name: 'be-macd',
|
||||
shortName: 'MACD',
|
||||
figures: [
|
||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: DIF_C }) },
|
||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: DEA_C }) },
|
||||
{
|
||||
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
|
||||
styles: (p) => {
|
||||
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
|
||||
return { color: v >= 0 ? UP : DOWN };
|
||||
},
|
||||
},
|
||||
],
|
||||
calc: (dataList: KLineData[]) =>
|
||||
dataList.map((_, i) => ({
|
||||
dif: BE_SERIES.macd?.[i] ?? undefined,
|
||||
dea: BE_SERIES.signal?.[i] ?? undefined,
|
||||
hist: BE_SERIES.hist?.[i] ?? undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
registerIndicator({
|
||||
name: 'be-lines',
|
||||
shortName: 'MA',
|
||||
figures: [
|
||||
{ key: 'fast', title: '快线', type: 'line', styles: () => ({ color: DIF_C }) },
|
||||
{ key: 'slow', title: '慢线', type: 'line', styles: () => ({ color: DEA_C }) },
|
||||
{ key: 'ma', title: '均线', type: 'line', styles: () => ({ color: MA_COLORS[2] }) },
|
||||
],
|
||||
calc: (dataList: KLineData[]) =>
|
||||
dataList.map((_, i) => ({
|
||||
fast: BE_SERIES.fast?.[i] ?? undefined,
|
||||
slow: BE_SERIES.slow?.[i] ?? undefined,
|
||||
ma: BE_SERIES.ma?.[i] ?? undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
// ---------- 画线工具 ----------
|
||||
const TOOLS: { name: string | null; label: string; title: string }[] = [
|
||||
{ name: null, label: '指针', title: '浏览模式(点击已画图形可选中/拖动/编辑)' },
|
||||
{ name: 'segment', label: '线段', title: '线段' },
|
||||
{ name: 'horizontalStraightLine', label: '水平线', title: '水平直线' },
|
||||
{ name: 'verticalStraightLine', label: '垂直线', title: '垂直直线' },
|
||||
{ name: 'rectangle', label: '矩形', title: '矩形区域' },
|
||||
{ name: 'fibonacciSegment', label: '斐波那契', title: '斐波那契回调' },
|
||||
{ name: 'priceChannelLine', label: '通道线', title: '价格通道线' },
|
||||
];
|
||||
const activeTool = ref<string | null>(null);
|
||||
|
||||
function pickTool(name: string) {
|
||||
activeTool.value = name;
|
||||
chart?.createOverlay({ name });
|
||||
}
|
||||
|
||||
function clearDrawings() {
|
||||
if (!chart) return;
|
||||
chart.removeOverlay(); // 清除全部(含买卖点标注),随后重建标注
|
||||
drawSignalAnnotations();
|
||||
}
|
||||
|
||||
// ---------- 图表 ----------
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let chart: Chart | null = null;
|
||||
|
||||
interface DayRec {
|
||||
open: number; high: number; low: number; close: number; volume: number;
|
||||
prevClose: number | null; ind: Record<string, number | null>;
|
||||
}
|
||||
let byIndex: DayRec[] = [];
|
||||
let candleData: KLineData[] = [];
|
||||
|
||||
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
|
||||
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
|
||||
|
||||
const fmt2 = (v: number | null) => (v == null ? '—' : v.toFixed(2));
|
||||
const fmt3 = (v: number | null) => (v == null ? '—' : v.toFixed(3));
|
||||
function weekdayOf(s: string) { return WD[new Date(s + 'T00:00:00').getDay()] ?? ''; }
|
||||
|
||||
function buildTip(rec: DayRec, ts: string) {
|
||||
const prev = rec.prevClose ?? rec.open;
|
||||
const change = rec.close - prev;
|
||||
return {
|
||||
date: ts.slice(0, 10), weekday: weekdayOf(ts.slice(0, 10)),
|
||||
open: rec.open, high: rec.high, low: rec.low, close: rec.close,
|
||||
change, chgPct: prev ? (change / prev) * 100 : 0,
|
||||
amplitude: prev ? ((rec.high - rec.low) / prev) * 100 : 0,
|
||||
volLots: Math.round(rec.volume / 100),
|
||||
ind: rec.ind, up: change >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 浅色主题 + A股红涨绿跌(与默认样式深合并)
|
||||
const LIGHT_STYLES = {
|
||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||
candle: {
|
||||
bar: {
|
||||
upColor: UP, downColor: DOWN,
|
||||
upBorderColor: UP, downBorderColor: DOWN,
|
||||
upWickColor: UP, downWickColor: DOWN,
|
||||
},
|
||||
priceMark: {
|
||||
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
|
||||
last: { upColor: UP, downColor: DOWN },
|
||||
},
|
||||
},
|
||||
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
crosshair: {
|
||||
horizontal: { text: { backgroundColor: '#1e293b' } },
|
||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||
},
|
||||
separator: { color: '#e2e8f0' },
|
||||
};
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
BE_SERIES = props.indicators.data ?? {};
|
||||
|
||||
const ch = init(container.value, { styles: LIGHT_STYLES });
|
||||
if (!ch) return;
|
||||
chart = ch;
|
||||
|
||||
// 逐日记录(悬停详情)
|
||||
const c = props.candles;
|
||||
const keys = Object.keys(BE_SERIES);
|
||||
byIndex = c.map((k, i) => {
|
||||
const ind: Record<string, number | null> = {};
|
||||
keys.forEach((key) => { ind[key] = BE_SERIES[key]?.[i] ?? null; });
|
||||
return {
|
||||
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
||||
prevClose: i > 0 ? c[i - 1].close : null, ind,
|
||||
};
|
||||
});
|
||||
candleData = c.map((k) => ({
|
||||
timestamp: new Date(k.ts).getTime(),
|
||||
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
||||
}));
|
||||
const tsList = c.map((k) => k.ts);
|
||||
|
||||
// v10 数据接入:DataLoader 一次性提供全量(回测结果静态数据,无分页)
|
||||
// 注意:v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一图表空白
|
||||
ch.setDataLoader({
|
||||
getBars: ({ type, callback }) => {
|
||||
if (type === 'update') {
|
||||
const last = candleData[candleData.length - 1];
|
||||
callback(last ? [last] : [], { backward: false, forward: false });
|
||||
} else if (type === 'init') {
|
||||
callback(candleData, { backward: false, forward: false });
|
||||
} else {
|
||||
callback([], { backward: false, forward: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
ch.setSymbol({ ticker: props.symbol ?? 'BACKTEST' });
|
||||
ch.setPeriod({ type: 'day', span: 1 });
|
||||
|
||||
// 副图/叠加:MACD 独立 pane;均线叠加主图
|
||||
ch.createIndicator('VOL');
|
||||
if (isMACD.value) {
|
||||
ch.createIndicator('be-macd');
|
||||
} else {
|
||||
ch.createIndicator({ name: 'be-lines', paneId: 'candle_pane' });
|
||||
}
|
||||
// 副图压矮,主图占大头
|
||||
for (const ind of ch.getIndicators()) {
|
||||
if (ind.name === 'VOL') ch.setPaneOptions({ id: ind.paneId, height: 84 });
|
||||
if (ind.name === 'be-macd') ch.setPaneOptions({ id: ind.paneId, height: 120 });
|
||||
}
|
||||
|
||||
drawSignalAnnotations();
|
||||
|
||||
// 悬停详情:crosshair 事件自带数据索引与像素坐标
|
||||
ch.subscribeAction('onCrosshairChange', (d) => {
|
||||
const data = d as Crosshair | undefined;
|
||||
const i = data?.dataIndex;
|
||||
if (data == null || i == null || i < 0 || i >= byIndex.length) {
|
||||
tip.value.visible = false;
|
||||
return;
|
||||
}
|
||||
tipData.value = buildTip(byIndex[i], tsList[i]);
|
||||
const el = container.value;
|
||||
if (el && data.x != null && data.y != null) {
|
||||
const TW = 224, TH = 196;
|
||||
let x = data.x + 16; if (x + TW > el.clientWidth) x = data.x - TW - 16; if (x < 4) x = 4;
|
||||
let y = data.y + 16; if (y + TH > el.clientHeight) y = data.y - TH - 24; if (y < 4) y = 4;
|
||||
tip.value = { visible: true, x, y };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawSignalAnnotations() {
|
||||
if (!chart) return;
|
||||
const c = props.candles;
|
||||
const idxOfTs = new Map<number, number>();
|
||||
c.forEach((k, i) => idxOfTs.set(new Date(k.ts).getTime(), i));
|
||||
for (const s of props.signals) {
|
||||
const i = idxOfTs.get(new Date(s.ts).getTime());
|
||||
if (i == null) continue;
|
||||
const k = c[i];
|
||||
const buy = s.side === 'buy';
|
||||
chart.createOverlay({
|
||||
name: 'simpleAnnotation',
|
||||
points: [{ dataIndex: i, value: buy ? k.low : k.high }],
|
||||
extendData: buy ? 'B' : 'S',
|
||||
styles: { text: { color: buy ? UP : DOWN, size: 11, weight: 'bold' } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
}
|
||||
|
||||
onMounted(build);
|
||||
onBeforeUnmount(teardown);
|
||||
watch(() => [props.candles, props.indicators, props.signals, props.strategy], () => { teardown(); build(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- 图例 + 画线工具栏 -->
|
||||
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-2">
|
||||
<span class="text-[13px] font-semibold text-slate-900">
|
||||
{{ symbol ?? '—' }}
|
||||
<small class="ml-1.5 font-normal text-slate-400">
|
||||
{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}
|
||||
</small>
|
||||
</span>
|
||||
<span v-for="(chip, i) in legendChips" :key="i" class="flex items-center gap-1 text-xs text-slate-500">
|
||||
<i class="inline-block h-0.5 w-3 rounded" :style="{ background: chip.color }"></i>{{ chip.label }}
|
||||
</span>
|
||||
|
||||
<span class="ml-auto flex items-center gap-0.5 rounded-lg border border-slate-200 bg-slate-50 p-0.5">
|
||||
<span class="px-1.5 text-[10px] text-slate-400">画线</span>
|
||||
<button
|
||||
v-for="tool in TOOLS"
|
||||
:key="tool.label"
|
||||
type="button"
|
||||
:title="tool.title"
|
||||
class="rounded-md px-2 py-1 text-xs transition-colors"
|
||||
:class="activeTool === tool.name ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-white hover:shadow-sm'"
|
||||
@click="tool.name === null ? (activeTool = null) : pickTool(tool.name)"
|
||||
>{{ tool.label }}</button>
|
||||
<button type="button" class="rounded-md px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-white hover:text-red-600" title="清除所有画线与标注" @click="clearDrawings">清除</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 悬停详情(同花顺式) -->
|
||||
<div
|
||||
v-if="tip.visible && tipData"
|
||||
class="pointer-events-none absolute z-20 w-[224px] rounded-lg border border-slate-200 bg-white/95 px-3 py-2 text-[11.5px] leading-relaxed text-slate-600 shadow-lg"
|
||||
:style="{ left: tip.x + 'px', top: tip.y + 'px' }"
|
||||
>
|
||||
<div class="mb-0.5 font-semibold text-slate-900">{{ tipData.date }} <span class="ml-1 font-normal text-slate-400">{{ tipData.weekday }}</span></div>
|
||||
<div class="grid grid-cols-2 gap-x-4">
|
||||
<div>开 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.open) }}</b></div>
|
||||
<div>高 <b class="text-up">{{ fmt2(tipData.high) }}</b></div>
|
||||
<div>低 <b class="text-down">{{ fmt2(tipData.low) }}</b></div>
|
||||
<div>收 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.close) }}</b></div>
|
||||
</div>
|
||||
<div>
|
||||
涨跌 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
|
||||
· 涨幅 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.chgPct.toFixed(2) }}%</b>
|
||||
</div>
|
||||
<div>振幅 {{ tipData.amplitude.toFixed(2) }}% · 量 {{ tipData.volLots.toLocaleString() }} 手</div>
|
||||
<div class="mt-1 border-t border-slate-100 pt-1">
|
||||
<span v-for="(v, k) in tipData.ind" :key="k" class="mr-3" :style="{ color: IND_COLOR[k] ?? '#64748b' }">
|
||||
{{ IND_LABEL[k] ?? k }} {{ fmt3(v) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="container" class="h-[520px] w-full"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MetricsOut } from '@/api/types';
|
||||
|
||||
defineProps<{ metrics: MetricsOut }>();
|
||||
|
||||
const pct = (x: number) => `${(x * 100).toFixed(2)}%`;
|
||||
const num = (x: number) => x.toFixed(2);
|
||||
// A股语义:正=红、负=绿
|
||||
const sign = (x: number) => (x >= 0 ? 'text-up' : 'text-down');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs text-slate-400">总收益</div>
|
||||
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs text-slate-400">最大回撤</div>
|
||||
<div class="mt-1 text-lg font-semibold text-down">{{ pct(metrics.max_drawdown) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs text-slate-400">夏普比率</div>
|
||||
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs text-slate-400">年化波动</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.volatility) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs text-slate-400">胜率</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.win_rate) }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs text-slate-400">交易次数</div>
|
||||
<div class="mt-1 text-lg font-semibold text-slate-900">{{ metrics.num_trades }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
|
||||
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
|
||||
|
||||
defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'run', text: string): void }>();
|
||||
const props = defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
|
||||
(e: 'ran'): void;
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
|
||||
@@ -10,22 +15,105 @@ const text = ref('');
|
||||
const examples = [
|
||||
'帮我找出这两天 KDJ 中的 J 小于 10,市值大于 100 亿,小于 200 亿的公司',
|
||||
'RSI 低于 30,市盈率 TTM 小于 20 的公司',
|
||||
'近 5 天曾经 MACD 金叉(DIF 上穿 DEA),换手率大于 5%,流通市值小于 100 亿',
|
||||
'股价在布林带下轨之下,流通市值小于 50 亿',
|
||||
];
|
||||
|
||||
function run() {
|
||||
if (text.value.trim()) emit('run', text.value.trim());
|
||||
if (text.value.trim()) {
|
||||
emit('run', text.value.trim());
|
||||
emit('ran');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
|
||||
const history = ref<ScreenerQueryItem[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
async function loadHistory() {
|
||||
historyOpen.value = !historyOpen.value;
|
||||
if (historyOpen.value) await refreshHistory();
|
||||
}
|
||||
async function refreshHistory() {
|
||||
try {
|
||||
history.value = await getScreenerQueries(20);
|
||||
} catch { history.value = []; }
|
||||
}
|
||||
|
||||
function rerun(q: ScreenerQueryItem) {
|
||||
text.value = q.text;
|
||||
historyOpen.value = false;
|
||||
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
|
||||
emit('run', q.text, q.conditions ?? null);
|
||||
emit('ran');
|
||||
}
|
||||
|
||||
async function removeQuery(id: number) {
|
||||
try {
|
||||
await deleteScreenerQuery(id);
|
||||
await refreshHistory();
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
function fmtTime(s: string): string {
|
||||
return s.replace('T', ' ').slice(5, 16);
|
||||
}
|
||||
defineExpose({ refreshHistory });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<label class="lbl">用一句话描述你的选股条件</label>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="lbl !mb-0">用一句话描述你的选股条件</label>
|
||||
<!-- 提问历史 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded-md border border-slate-200 px-2.5 py-1 text-xs text-slate-500 transition-colors hover:text-slate-900"
|
||||
@click="loadHistory"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3" /><circle cx="12" cy="12" r="9" /></svg>
|
||||
提问历史
|
||||
</button>
|
||||
<div
|
||||
v-if="historyOpen"
|
||||
class="absolute right-0 top-8 z-30 w-[26rem] rounded-lg border border-slate-200 bg-white shadow-lg"
|
||||
>
|
||||
<div v-if="history.length === 0" class="px-4 py-6 text-center text-xs text-slate-400">暂无历史提问</div>
|
||||
<div v-else class="max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="q in history"
|
||||
:key="q.id"
|
||||
class="group flex items-start gap-2 border-b border-slate-50 px-3 py-2 last:border-0 hover:bg-slate-50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left"
|
||||
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
|
||||
@click="rerun(q)"
|
||||
>
|
||||
<span class="block truncate text-[13px] text-slate-700">{{ q.text }}</span>
|
||||
<span class="mt-0.5 block text-[11px] text-slate-400">
|
||||
{{ fmtTime(q.created_at) }}
|
||||
<span v-if="q.hit_count != null" class="ml-1 rounded bg-slate-100 px-1">命中 {{ q.hit_count }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-slate-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
title="删除该记录"
|
||||
@click.stop="removeQuery(q.id)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="text"
|
||||
rows="2"
|
||||
class="ipt w-full resize-y leading-relaxed"
|
||||
class="ipt mt-2 w-full resize-y leading-relaxed"
|
||||
placeholder="例如:这两天 KDJ 的 J 小于 10,市值 100~200 亿的公司"
|
||||
@keyup.ctrl.enter="run"
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,10 @@ function toggleSort(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 按当日涨跌着色(跟随设置中的涨跌配色)
|
||||
const toneClass = (v: number | null | undefined) =>
|
||||
v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '';
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const key = sortKey.value;
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1;
|
||||
@@ -112,8 +116,8 @@ function fmtInd(it: ScreenerItemOut, key: string) {
|
||||
>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 font-medium text-slate-900">{{ it.ts_code }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ it.name }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5">{{ fmt2(it.close) }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5" :class="it.pct_chg != null && FIXED_COLS[3].cls ? FIXED_COLS[3].cls!(it.pct_chg) : ''">
|
||||
<td class="whitespace-nowrap px-3 py-1.5 font-medium tabular-nums" :class="toneClass(it.pct_chg)">{{ fmt2(it.close) }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 tabular-nums" :class="toneClass(it.pct_chg)">
|
||||
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { getStockPreview } from '@/api/client';
|
||||
import type { PreviewResponse, ScreenerItemOut } from '@/api/types';
|
||||
import { addWatchlist, getStockPreview, getWatchlist as getWatchlistApi, removeWatchlist } from '@/api/client';
|
||||
import type { ChartLayoutPrefs, PreviewResponse, ScreenerItemOut, Timeframe } from '@/api/types';
|
||||
import { useSettingsStore, type PriceAdjust } from '@/stores/settings';
|
||||
import DetailKLine from './DetailKLine.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
items: ScreenerItemOut[];
|
||||
initial: string; // ts_code
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'watched-change'): void }>();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
// ---------- 状态 ----------
|
||||
const active = ref(props.initial);
|
||||
@@ -17,16 +19,123 @@ const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const filter = ref('');
|
||||
|
||||
// 副图指标:点击开关 / 拖拽排序
|
||||
// 复权切换(持久化到设置;切换即重拉)
|
||||
const ADJUSTS: { key: PriceAdjust; label: string }[] = [
|
||||
{ key: 'bfq', label: '不复权' },
|
||||
{ key: 'qfq', label: '前复权' },
|
||||
{ key: 'hfq', label: '后复权' },
|
||||
];
|
||||
const adjust = computed(() => settings.priceAdjust);
|
||||
function setAdjust(key: PriceAdjust) {
|
||||
settings.setPriceAdjust(key);
|
||||
}
|
||||
|
||||
// K线周期切换(持久化到用户偏好)
|
||||
const PERIODS: { key: Timeframe; label: string }[] = [
|
||||
{ key: '1d', label: '日K' },
|
||||
{ key: '1w', label: '周K' },
|
||||
{ key: '1M', label: '月K' },
|
||||
{ key: '1y', label: '年K' },
|
||||
];
|
||||
const timeframe = ref<Timeframe>((settings.chartLayout.timeframe as Timeframe) ?? '1d');
|
||||
function setTimeframe(tf: Timeframe) {
|
||||
timeframe.value = tf;
|
||||
settings.setChartLayout({ timeframe: tf });
|
||||
}
|
||||
|
||||
// 数据口径徽标:market=近段未复权兜底;其余为实际复权口径(可能因因子缺失与所选不同)
|
||||
const ADJUST_LABELS: Record<string, string> = { bfq: '不复权', qfq: '前复权', hfq: '后复权' };
|
||||
const sourceLabel = computed(() =>
|
||||
data.value ? (ADJUST_LABELS[data.value.source] ?? data.value.source) : '');
|
||||
|
||||
// ---------- 副图 / MA / 高度(全部随用户偏好持久化) ----------
|
||||
const SUBS = [
|
||||
{ key: 'vol', label: 'VOL' },
|
||||
{ key: 'macd', label: 'MACD' },
|
||||
{ key: 'kdj', label: 'KDJ' },
|
||||
{ key: 'rsi', label: 'RSI' },
|
||||
];
|
||||
const subPanes = ref<string[]>(['vol', 'macd', 'kdj', 'rsi']);
|
||||
const layout = computed<ChartLayoutPrefs>(() => settings.chartLayout);
|
||||
const subPanes = computed<string[]>(() => layout.value.subPanes);
|
||||
const maPeriods = computed<number[]>(() => layout.value.maPeriods);
|
||||
const subHeights = computed(() => layout.value.subHeights);
|
||||
const showBoll = ref(false);
|
||||
|
||||
function toggleSub(key: string) {
|
||||
const cur = subPanes.value;
|
||||
settings.setChartLayout({
|
||||
subPanes: cur.includes(key) ? cur.filter((k) => k !== key) : [...cur, key],
|
||||
});
|
||||
}
|
||||
function adjustHeight(key: string, delta: number) {
|
||||
const DEFAULTS: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
|
||||
const base = subHeights.value;
|
||||
const next = Math.max(40, (base[key] ?? DEFAULTS[key] ?? 90) + delta);
|
||||
settings.setChartLayout({ subHeights: { ...base, [key]: next } });
|
||||
}
|
||||
|
||||
// 副图拖拽排序
|
||||
let dragKey: string | null = null;
|
||||
function onDragStart(e: DragEvent, key: string) {
|
||||
dragKey = key;
|
||||
e.dataTransfer?.setData('text/plain', key);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
function onDrop(target: string) {
|
||||
if (!dragKey || dragKey === target) return;
|
||||
const arr = [...subPanes.value];
|
||||
const from = arr.indexOf(dragKey);
|
||||
if (from >= 0) arr.splice(from, 1);
|
||||
const to = arr.indexOf(target);
|
||||
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
|
||||
settings.setChartLayout({ subPanes: arr });
|
||||
dragKey = null;
|
||||
}
|
||||
|
||||
// ---------- MA 配置(弹层) ----------
|
||||
const MA_PRESETS = [5, 10, 20, 30, 60, 120, 250];
|
||||
const showMaConfig = ref(false);
|
||||
const customMa = ref('');
|
||||
function toggleMa(p: number) {
|
||||
const cur = maPeriods.value;
|
||||
settings.setChartLayout({
|
||||
maPeriods: cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p].sort((a, b) => a - b),
|
||||
});
|
||||
}
|
||||
function addCustomMa() {
|
||||
const v = parseInt(customMa.value, 10);
|
||||
if (v >= 1 && v <= 500 && !maPeriods.value.includes(v)) {
|
||||
settings.setChartLayout({ maPeriods: [...maPeriods.value, v].sort((a, b) => a - b) });
|
||||
}
|
||||
customMa.value = '';
|
||||
showMaConfig.value = false;
|
||||
}
|
||||
|
||||
// ---------- 自选股(星标) ----------
|
||||
const watched = ref(false);
|
||||
const watchBusy = ref(false);
|
||||
const watchedSet = ref<Set<string>>(new Set());
|
||||
async function refreshWatched() {
|
||||
try {
|
||||
watchedSet.value = new Set(await getWatchlistApi());
|
||||
} catch { /* 未登录等场景忽略 */ }
|
||||
watched.value = watchedSet.value.has(active.value);
|
||||
}
|
||||
async function toggleWatch() {
|
||||
if (watchBusy.value) return;
|
||||
watchBusy.value = true;
|
||||
try {
|
||||
const list = watched.value
|
||||
? await removeWatchlist(active.value)
|
||||
: await addWatchlist(active.value);
|
||||
watchedSet.value = new Set(list);
|
||||
watched.value = watchedSet.value.has(active.value);
|
||||
emit('watched-change');
|
||||
} catch { /* 忽略 */ } finally {
|
||||
watchBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
if (!q) return props.items;
|
||||
@@ -50,7 +159,7 @@ const header = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
// ---------- 数据加载(拉全量历史,图表内按需分页展示) ----------
|
||||
let fetchToken = 0;
|
||||
async function load(code: string) {
|
||||
const token = ++fetchToken;
|
||||
@@ -58,7 +167,12 @@ async function load(code: string) {
|
||||
error.value = null;
|
||||
data.value = null;
|
||||
try {
|
||||
const res = await getStockPreview(code);
|
||||
const res = await getStockPreview(code, {
|
||||
limit: 30000,
|
||||
adjust: adjust.value,
|
||||
timeframe: timeframe.value,
|
||||
mas: maPeriods.value,
|
||||
});
|
||||
if (token === fetchToken) data.value = res;
|
||||
} catch (e) {
|
||||
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
|
||||
@@ -67,6 +181,16 @@ async function load(code: string) {
|
||||
}
|
||||
}
|
||||
watch(active, (code) => load(code), { immediate: true });
|
||||
watch(adjust, () => load(active.value));
|
||||
watch(timeframe, () => load(active.value));
|
||||
// MA 周期变化也要重拉(后端按 mas 计算指标序列)
|
||||
watch(maPeriods, () => load(active.value));
|
||||
|
||||
// 切股时同步自选状态
|
||||
watch(active, (code) => {
|
||||
watched.value = watchedSet.value.has(code);
|
||||
}, { immediate: true });
|
||||
void refreshWatched();
|
||||
|
||||
function moveActive(delta: number) {
|
||||
const list = filteredItems.value;
|
||||
@@ -78,11 +202,10 @@ function moveActive(delta: number) {
|
||||
|
||||
// ---------- 键盘 / 滚动锁 ----------
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// 输入法组合态 / 焦点在输入框时不拦截(否则搜索框打字会切股、Esc 关浮层)
|
||||
if (e.isComposing) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if (e.key === 'Escape') emit('close');
|
||||
if (e.key === 'Escape') { if (showMaConfig.value) showMaConfig.value = false; else emit('close'); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
|
||||
}
|
||||
@@ -95,29 +218,28 @@ onBeforeUnmount(() => {
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
// ---------- 副图 chips:开关 + 拖拽排序 ----------
|
||||
let dragKey: string | null = null;
|
||||
function toggleSub(key: string) {
|
||||
subPanes.value = subPanes.value.includes(key)
|
||||
? subPanes.value.filter((k) => k !== key)
|
||||
: [...subPanes.value, key];
|
||||
}
|
||||
function onDragStart(e: DragEvent, key: string) {
|
||||
dragKey = key;
|
||||
// Firefox/Safari 要求 dragstart 写入数据才会真正发起拖拽
|
||||
e.dataTransfer?.setData('text/plain', key);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
function onDrop(target: string) {
|
||||
if (!dragKey || dragKey === target) return;
|
||||
const arr = [...subPanes.value];
|
||||
const from = arr.indexOf(dragKey);
|
||||
if (from >= 0) arr.splice(from, 1);
|
||||
const to = arr.indexOf(target);
|
||||
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
|
||||
subPanes.value = arr;
|
||||
dragKey = null;
|
||||
}
|
||||
// ---------- 右侧信息栏增强:52周高低 / 年初至今(从日线序列算,无数据留空) ----------
|
||||
const stats = computed(() => {
|
||||
const bars = timeframe.value === '1d' ? data.value?.candles : null;
|
||||
if (!bars || bars.length === 0) return { high52: null, low52: null, ytd: null };
|
||||
const last = bars[bars.length - 1];
|
||||
const lastTs = new Date(last.ts);
|
||||
const yearStart = new Date(lastTs.getFullYear(), 0, 1).getTime();
|
||||
let high = -Infinity, low = Infinity;
|
||||
let ytdBase: number | null = null;
|
||||
const cutoff = lastTs.getTime() - 365 * 24 * 3600 * 1000;
|
||||
for (const b of bars) {
|
||||
const t = new Date(b.ts).getTime();
|
||||
if (t >= cutoff) { high = Math.max(high, b.high); low = Math.min(low, b.low); }
|
||||
// 年初至今基准 = 上一年最后一根收盘
|
||||
if (t < yearStart) ytdBase = b.close;
|
||||
}
|
||||
return {
|
||||
high52: high === -Infinity ? null : high,
|
||||
low52: low === Infinity ? null : low,
|
||||
ytd: ytdBase && ytdBase !== 0 ? ((last.close - ytdBase) / ytdBase) * 100 : null,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- 格式化 ----------
|
||||
const fmt = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
|
||||
@@ -131,7 +253,20 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
<template>
|
||||
<div class="fixed inset-0 z-40 flex flex-col bg-slate-100">
|
||||
<!-- 顶栏 -->
|
||||
<header class="flex h-12 shrink-0 items-center gap-4 border-b border-slate-200 bg-white px-4">
|
||||
<header class="flex h-12 shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4">
|
||||
<!-- 自选星标 -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-1 transition-colors hover:bg-slate-100 disabled:opacity-50"
|
||||
:class="watched ? 'text-amber-500' : 'text-slate-300'"
|
||||
:title="watched ? '移出自选' : '加入自选'"
|
||||
:disabled="watchBusy"
|
||||
@click="toggleWatch"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" :fill="watched ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span>
|
||||
<span class="text-xs text-slate-400">{{ active }}</span>
|
||||
@@ -142,12 +277,39 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
{{ header.pct > 0 ? '+' : '' }}{{ fmt(header.pct) }}%
|
||||
</span>
|
||||
</div>
|
||||
<!-- 周期切换 -->
|
||||
<div class="flex rounded-md border border-slate-200 p-0.5 text-[11px]">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="rounded px-2 py-0.5 transition-colors"
|
||||
:class="timeframe === p.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:text-slate-900'"
|
||||
@click="setTimeframe(p.key)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
<!-- 复权切换 -->
|
||||
<div class="flex rounded-md border border-slate-200 p-0.5 text-[11px]">
|
||||
<button
|
||||
v-for="a in ADJUSTS"
|
||||
:key="a.key"
|
||||
type="button"
|
||||
class="rounded px-2 py-0.5 transition-colors"
|
||||
:class="adjust === a.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:text-slate-900'"
|
||||
@click="setAdjust(a.key)"
|
||||
>{{ a.label }}</button>
|
||||
</div>
|
||||
<span v-if="data?.source === 'market'" class="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-600">
|
||||
近段未复权数据
|
||||
</span>
|
||||
<span v-else-if="data" class="rounded bg-blue-50 px-2 py-0.5 text-[11px] text-blue-600">前复权</span>
|
||||
<span
|
||||
v-else-if="data"
|
||||
class="rounded px-2 py-0.5 text-[11px]"
|
||||
:class="data.source === adjust ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'"
|
||||
:title="data.source === adjust ? '' : '该股复权因子缺失,暂按此口径显示(可先同步市场数据)'"
|
||||
>{{ sourceLabel }}</span>
|
||||
|
||||
<span class="ml-auto text-xs text-slate-400">↑↓ 切换 · Esc 关闭</span>
|
||||
<span class="ml-auto text-xs text-slate-400">↑↓ 切换 · Esc 关闭 · 滚轮缩放 · 左滑加载历史</span>
|
||||
<button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
@@ -174,7 +336,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
<span class="block text-[11px] text-slate-400">{{ it.ts_code }}</span>
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<span class="block text-[13px]">{{ fmt(it.close) }}</span>
|
||||
<span class="block text-[13px] font-medium" :class="pctClass(it.pct_chg)">{{ fmt(it.close) }}</span>
|
||||
<span class="block text-[11px]" :class="pctClass(it.pct_chg)">
|
||||
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
|
||||
</span>
|
||||
@@ -187,26 +349,35 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
|
||||
<!-- 中:K线 + 指标面板 -->
|
||||
<section class="flex min-w-0 flex-1 flex-col">
|
||||
<!-- 指标开关 / 排序 -->
|
||||
<!-- 指标开关 / 排序 / MA 配置 -->
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-white px-3 py-2">
|
||||
<span class="text-[11px] text-slate-400">副图:</span>
|
||||
<button
|
||||
<div
|
||||
v-for="s in SUBS"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
draggable="true"
|
||||
class="cursor-grab rounded-md border px-2.5 py-1 text-xs transition-colors active:cursor-grabbing"
|
||||
:class="subPanes.includes(s.key)
|
||||
? 'border-blue-600 bg-blue-600 text-white'
|
||||
: 'border-slate-200 bg-white text-slate-400 line-through'"
|
||||
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序' : '点击显示'"
|
||||
@click="toggleSub(s.key)"
|
||||
@dragstart="onDragStart($event, s.key)"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(s.key)"
|
||||
class="flex items-center overflow-hidden rounded-md border"
|
||||
:class="subPanes.includes(s.key) ? 'border-blue-600' : 'border-slate-200'"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
draggable="true"
|
||||
class="px-2.5 py-1 text-xs transition-colors"
|
||||
:class="subPanes.includes(s.key)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-slate-400 line-through'"
|
||||
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序 · 右侧按钮调高度' : '点击显示'"
|
||||
@click="toggleSub(s.key)"
|
||||
@dragstart="onDragStart($event, s.key)"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(s.key)"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
<template v-if="subPanes.includes(s.key)">
|
||||
<button type="button" class="border-l px-1 py-1 text-[10px] text-slate-400 hover:bg-slate-100 hover:text-slate-700" title="调高" @click="adjustHeight(s.key, 20)">▲</button>
|
||||
<button type="button" class="border-l px-1 py-1 text-[10px] text-slate-400 hover:bg-slate-100 hover:text-slate-700" title="调矮" @click="adjustHeight(s.key, -20)">▼</button>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-2.5 py-1 text-xs transition-colors"
|
||||
@@ -214,14 +385,50 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
title="主图叠加布林带"
|
||||
@click="showBoll = !showBoll"
|
||||
>BOLL</button>
|
||||
<span class="ml-2 text-[11px] text-slate-400">点击开关副图 · 拖动排序 · 滚轮缩放 · 拖拽平移</span>
|
||||
<!-- MA 配置 -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-500 transition-colors hover:text-slate-900"
|
||||
@click="showMaConfig = !showMaConfig"
|
||||
>MA 设置</button>
|
||||
<div
|
||||
v-if="showMaConfig"
|
||||
class="absolute left-0 top-8 z-20 w-52 rounded-lg border border-slate-200 bg-white p-2.5 shadow-lg"
|
||||
>
|
||||
<div class="mb-2 text-[11px] text-slate-400">勾选主图显示的均线</div>
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<label
|
||||
v-for="p in MA_PRESETS"
|
||||
:key="p"
|
||||
class="flex cursor-pointer items-center justify-center rounded border px-1 py-1 text-xs"
|
||||
:class="maPeriods.includes(p) ? 'border-blue-600 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-500'"
|
||||
>
|
||||
<input type="checkbox" class="hidden" :checked="maPeriods.includes(p)" @change="toggleMa(p)" />
|
||||
MA{{ p }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<input
|
||||
v-model="customMa"
|
||||
type="number" min="1" max="500"
|
||||
class="ipt w-full !py-1 text-xs"
|
||||
placeholder="自定义周期"
|
||||
@keyup.enter="addCustomMa"
|
||||
/>
|
||||
<button type="button" class="btn-primary !px-2 !py-1 text-xs" @click="addCustomMa">加</button>
|
||||
</div>
|
||||
<div class="mt-1.5 text-[11px] text-slate-400">当前:{{ maPeriods.map((p: number) => 'MA' + p).join(' / ') || '无' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ml-auto text-[11px] text-slate-400">点击开关 · 拖动排序 · ▲▼调高度 · 右上工具栏画线</span>
|
||||
</div>
|
||||
|
||||
<!-- 图表 -->
|
||||
<div class="relative min-h-0 flex-1 bg-white p-1">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 text-sm text-slate-400">
|
||||
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
|
||||
{{ active }} 首次查看需拉取全量日线…
|
||||
{{ active }} 加载日线中…
|
||||
</div>
|
||||
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div>
|
||||
<DetailKLine
|
||||
@@ -230,7 +437,10 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
:candles="data.candles"
|
||||
:indicators="data.indicators"
|
||||
:sub-panes="subPanes"
|
||||
:ma-periods="maPeriods"
|
||||
:sub-heights="subHeights"
|
||||
:show-boll="showBoll"
|
||||
:timeframe="timeframe"
|
||||
/>
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div>
|
||||
</div>
|
||||
@@ -265,6 +475,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
['市净率', fmt(data.info.pb)],
|
||||
['总市值', fmt(data.info.total_mv) + ' 亿'],
|
||||
['流通市值', fmt(data.info.circ_mv) + ' 亿'],
|
||||
['52周最高', fmt(stats.high52)],
|
||||
['52周最低', fmt(stats.low52)],
|
||||
['年初至今', stats.ytd == null ? '—' : (stats.ytd > 0 ? '+' : '') + stats.ytd.toFixed(2) + '%'],
|
||||
['上市日期', fmtListDate(data.info.list_date)],
|
||||
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
|
||||
]" :key="i">
|
||||
@@ -273,6 +486,22 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 股本/分红/股东(数据未接入前留空占位) -->
|
||||
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]">
|
||||
<div class="mb-2 text-xs text-slate-400">股本 / 分红 / 股东</div>
|
||||
<div class="grid grid-cols-2 gap-y-2">
|
||||
<template v-for="(row, i) in [
|
||||
['股东户数', '—'],
|
||||
['户均持股', '—'],
|
||||
['分红率', '—'],
|
||||
['股息率', '—'],
|
||||
]" :key="i">
|
||||
<span class="text-slate-400">{{ row[0] }}</span>
|
||||
<span class="text-right text-slate-300" title="数据源待接入">{{ row[1] }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]">
|
||||
<div class="mb-2 text-xs text-slate-400">归属</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
|
||||
Reference in New Issue
Block a user