提交
This commit is contained in:
@@ -12,6 +12,7 @@ import type {
|
||||
PreviewResponse,
|
||||
ScreenerQueryItem,
|
||||
ScreenerRunRequest,
|
||||
ScreenerStreamEvent,
|
||||
ScreenerRunResponse,
|
||||
ScreenerSyncRequest,
|
||||
ScreenerSyncStatus,
|
||||
@@ -120,10 +121,38 @@ export async function postEventBacktest(req: EventBacktestRequest): Promise<Even
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScreener(req: ScreenerRunRequest): Promise<ScreenerRunResponse> {
|
||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) });
|
||||
/** 选股流式执行:逐行回调 NDJSON 事件(阶段/进度/解析条件),最终返回完整结果。
|
||||
* signal 中止后(含读流中途)以 AbortError 拒绝,由调用方决定如何呈现。 */
|
||||
export async function runScreener(
|
||||
req: ScreenerRunRequest,
|
||||
onEvent?: (ev: ScreenerStreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScreenerRunResponse> {
|
||||
const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req), signal });
|
||||
if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status);
|
||||
return (await res.json()) as ScreenerRunResponse;
|
||||
if (!res.body) throw new ApiError('当前环境不支持流式响应', 0);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let result: ScreenerRunResponse | null = null;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const ev = JSON.parse(line) as ScreenerStreamEvent;
|
||||
if (ev.type === 'error') throw new ApiError(ev.message, ev.code ?? 0);
|
||||
if (ev.type === 'result') result = ev.result;
|
||||
onEvent?.(ev);
|
||||
}
|
||||
}
|
||||
if (!result) throw new ApiError('选股响应中断(未收到结果事件)', 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise<ScreenerSyncStatus> {
|
||||
|
||||
@@ -148,6 +148,15 @@ export interface ScreenerRunResponse {
|
||||
indicator_labels: Record<string, string>;
|
||||
}
|
||||
|
||||
// ---------- 选股流式事件(镜像 api.py /screener/run 的 NDJSON 约定) ----------
|
||||
export type ScreenerStreamEvent =
|
||||
| { type: 'stage'; key: string; msg: string; ms?: number }
|
||||
| { type: 'parsed'; conditions: ScreenConditions; ms?: number }
|
||||
| { type: 'candidates'; count: number; msg?: string; ms?: number }
|
||||
| { type: 'progress'; done: number; total: number }
|
||||
| { type: 'result'; result: ScreenerRunResponse; ms?: number }
|
||||
| { type: 'error'; message: string; code?: number };
|
||||
|
||||
export interface ScreenerSyncRequest {
|
||||
days?: number;
|
||||
force?: boolean;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
threeWaves, triangle, xabcd,
|
||||
} from '@klinecharts/extension';
|
||||
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
|
||||
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, type ChipsExt } from '@/indicators';
|
||||
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from '@/indicators';
|
||||
import type { Candle, TooltipField } from '@/api/types';
|
||||
|
||||
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
|
||||
@@ -671,11 +671,62 @@ function syncChipsIndicator() {
|
||||
if (want) {
|
||||
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
|
||||
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
|
||||
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
|
||||
} else if (exists) {
|
||||
chart.removeIndicator({ name: CHIPS_NAME });
|
||||
restoreRightOffset();
|
||||
} else {
|
||||
restoreRightOffset(); // 数据未到即关:同样要还原已扩出去的留白
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 筹码峰竖列的右侧留白管理 ----------
|
||||
// klinecharts 滚动状态唯一变量是 diff(最后一根K距绘图区右缘的 bar 数),
|
||||
// setOffsetRightDistance(px) 会把 diff 直接重置为 px/barSpace 并 relayout——等价于
|
||||
// “视口拽回实时位并留 px 空白”。因此只在「右区」(末根K在视野内,getVisibleRange().to
|
||||
// 达到 data 长度)时调用;用户在看历史时绝不调,滚回右区后由 onScroll 防抖补一次吸附
|
||||
// (同花顺松手吸附同款)。绝不调 setMaxOffsetRightDistance:会把滚动限制切到
|
||||
// 'distance' 模式,默认钳到 50px 且改变滚动边缘行为。
|
||||
const BASE_RIGHT_PX = 28;
|
||||
let syncingRightOffset = false; // 重入守卫:set 会同步派发 onVisibleRangeChange
|
||||
let scrollReanchorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let managedRightPx = 0; // 筹码开启期间我们 set 过的目标值(关筹码时判断“是否还归我们管”)
|
||||
|
||||
/** 目标右侧留白:开筹码 = 列宽 + 一根K线 + 余量(bar 最宽 50px → 峰值 ~182px) */
|
||||
function targetRightPx(): number {
|
||||
if (!props.showChips) return BASE_RIGHT_PX;
|
||||
const bar = chart?.getBarSpace().bar || 10;
|
||||
return Math.round(CHIPS_COL_PX + bar + 12);
|
||||
}
|
||||
|
||||
/** 开筹码期间保证留白盖住竖列:只扩不缩(用户自己拖出的更大留白不动) */
|
||||
function syncRightOffset(): void {
|
||||
if (!chart || syncingRightOffset || !props.showChips) return;
|
||||
const list = chart.getDataList();
|
||||
if (!list.length || chart.getVisibleRange().to < list.length) return; // 右区守卫
|
||||
const target = targetRightPx();
|
||||
if (chart.getOffsetRightDistance() >= target - 2) return; // 阈值防循环 + 只扩不缩
|
||||
syncingRightOffset = true;
|
||||
try {
|
||||
chart.setOffsetRightDistance(target);
|
||||
managedRightPx = target;
|
||||
} finally {
|
||||
syncingRightOffset = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 关筹码时一次性还原 28px:仅当留白仍在我们管理范围内(用户没手动拖大过) */
|
||||
function restoreRightOffset(): void {
|
||||
if (!chart || managedRightPx === 0) return;
|
||||
const list = chart.getDataList();
|
||||
const cur = chart.getOffsetRightDistance();
|
||||
if (list.length && chart.getVisibleRange().to >= list.length
|
||||
&& cur > BASE_RIGHT_PX + 2 && cur <= managedRightPx + 2) {
|
||||
chart.setOffsetRightDistance(BASE_RIGHT_PX);
|
||||
}
|
||||
managedRightPx = 0;
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
UP = settings.upHex;
|
||||
@@ -801,7 +852,23 @@ function build() {
|
||||
const from = (payload as { data?: { from?: unknown } }).data?.from;
|
||||
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
|
||||
});
|
||||
ch.setOffsetRightDistance(28);
|
||||
// 右侧留白:开筹码时按竖列宽度预留(scrollToRealTime 以它为锚点,先后顺序不能换)
|
||||
ch.setOffsetRightDistance(props.showChips ? targetRightPx() : BASE_RIGHT_PX);
|
||||
// 筹码竖列的留白跟随:缩放后 barSpace 变化(onZoom 触发时新值已生效)立即补扩;
|
||||
// 拖拽期间 onScroll 逐帧触发、且拖拽每帧从手势起点快照重算 diff,逐帧同步会与拖拽
|
||||
// “互搏”(从实时位拖向历史会被逐帧拽回),故防抖 150ms 只在手势结束后补一次
|
||||
ch.subscribeAction('onZoom', () => {
|
||||
if (myEpoch !== epoch) return;
|
||||
syncRightOffset();
|
||||
});
|
||||
ch.subscribeAction('onScroll', () => {
|
||||
if (myEpoch !== epoch) return;
|
||||
if (scrollReanchorTimer) clearTimeout(scrollReanchorTimer);
|
||||
scrollReanchorTimer = setTimeout(() => {
|
||||
scrollReanchorTimer = null;
|
||||
if (myEpoch === epoch) syncRightOffset();
|
||||
}, 150);
|
||||
});
|
||||
ch.scrollToRealTime();
|
||||
// 日期跳转:build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
|
||||
//(init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
|
||||
@@ -814,6 +881,8 @@ function build() {
|
||||
|
||||
function teardown() {
|
||||
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
|
||||
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
|
||||
managedRightPx = 0;
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
hover.value = null;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
|
||||
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
|
||||
import type { ScreenerQueryItem } from '@/api/types';
|
||||
|
||||
const props = defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
|
||||
(e: 'run', text: string): void;
|
||||
(e: 'ran'): void;
|
||||
(e: 'stop'): void;
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
@@ -18,13 +19,14 @@ const examples = [
|
||||
];
|
||||
|
||||
function run() {
|
||||
if (props.loading) return; // 执行中 Ctrl+Enter 不重复触发(要停止点「停止」)
|
||||
if (text.value.trim()) {
|
||||
emit('run', text.value.trim());
|
||||
emit('ran');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
|
||||
// ---------- 提问历史(点击只填入输入框,不触发查询 / 删除) ----------
|
||||
const history = ref<ScreenerQueryItem[]>([]);
|
||||
const historyOpen = ref(false);
|
||||
|
||||
@@ -38,12 +40,9 @@ async function refreshHistory() {
|
||||
} catch { history.value = []; }
|
||||
}
|
||||
|
||||
function rerun(q: ScreenerQueryItem) {
|
||||
function fillQuery(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) {
|
||||
@@ -87,8 +86,8 @@ defineExpose({ refreshHistory });
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 text-left"
|
||||
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
|
||||
@click="rerun(q)"
|
||||
title="点击填入输入框(不自动执行)"
|
||||
@click="fillQuery(q)"
|
||||
>
|
||||
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
|
||||
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
|
||||
@@ -134,10 +133,19 @@ defineExpose({ refreshHistory });
|
||||
支持 KDJ / RSI / MACD / 布林 / 均线指标条件,市值 / 市盈率 / 换手率等快照条件,以及「连续 N 天」「近 N 天任一天」时间窗口。
|
||||
按 <kbd class="rounded border border-[#26272E] bg-black px-1">Ctrl</kbd>+<kbd class="rounded border border-[#26272E] bg-black px-1">Enter</kbd> 快速筛选。
|
||||
</p>
|
||||
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
|
||||
<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="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
{{ loading ? '筛选中…' : '开始筛选' }}
|
||||
<!-- 执行中变为「停止」:中止 LLM 解析与逐股过滤(服务端随连接断开自动取消) -->
|
||||
<button
|
||||
v-if="loading"
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 rounded-lg border border-red-500/50 bg-red-500/15 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/25"
|
||||
@click="emit('stop')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
|
||||
停止
|
||||
</button>
|
||||
<button v-else type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="!text.trim()" @click="run">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
|
||||
开始筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺式右侧横向线状直方图)
|
||||
// 个股筹码峰(Tushare cyq_chips / cyq_perf,同花顺火焰山式右侧专用竖列)
|
||||
//
|
||||
// 数据不在图表窗口上计算,而是一次性截面(某交易日各价位筹码占比 + 获利比例/平均成本),
|
||||
// 经 extendData 注入、纯 draw 绘制:
|
||||
// - 每个价位一根横线,从主图右缘向左延伸,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红线)、上方为套牢盘(蓝线)
|
||||
// - 平均成本画黄色虚线;右上角小字块:截面日期 / 获利比例 / 平均成本 / 90% 成本区间
|
||||
// - 主图绘图区右缘、价格轴左侧一条【不透明底色专用竖列】,K线不透出(同花顺同款)
|
||||
// - 每个价位一根横条,在列内从右缘向左伸展,宽度 ∝ 占比/最大占比(火焰山形态)
|
||||
// - 分界价 = 选中K线的收盘价(refClose):下方为获利盘(红)、上方为套牢盘(蓝)
|
||||
// - 平均成本画黄色虚线、末根收盘画现价细实线;列内顶部小字:截面日/获利比例/平均成本/90%成本
|
||||
// DetailKLine 依据 CHIPS_COL_PX 在开启筹码时加大右侧留白,保证最新K线不被竖列盖住。
|
||||
// figures 为空、calc 返回空行:指标不产生序列值,也不影响主图价格刻度(筹码是覆盖层)。
|
||||
import type { IndicatorTemplate } from 'klinecharts';
|
||||
|
||||
export const CHIPS_NAME = 'pv-chips';
|
||||
|
||||
/** 火焰山竖列基准宽度(px):DetailKLine 据此预留右侧留白,draw 据此画列 */
|
||||
export const CHIPS_COL_PX = 120;
|
||||
|
||||
/** 经 extendData 注入的筹码截面(父组件按 当前窗口末根/十字线 所在周期取数) */
|
||||
export interface ChipsExt {
|
||||
rows: { price: number; percent: number }[]; // percent 为 0~1 占比
|
||||
@@ -29,16 +34,34 @@ const AVG_COLOR = '#F5C518';
|
||||
const LABEL_COLOR = '#9AA0AA';
|
||||
const VALUE_COLOR = '#E8EAED';
|
||||
|
||||
/** 同花顺筹码峰同款:横线最大占主图宽度的比例与上限(px) */
|
||||
const MAX_WIDTH_RATIO = 0.22;
|
||||
const MAX_WIDTH_PX = 150;
|
||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满半个主图 */
|
||||
// 竖列几何:窄图下限 56px,最多占绘图区宽度 35%(实际宽度取两者与基准的钳制值)
|
||||
const COL_MIN_PX = 56;
|
||||
const COL_MAX_RATIO = 0.35;
|
||||
/** 列内左右内边距(横条右缘、统计文字与列缘的呼吸) */
|
||||
const COL_PAD = 5;
|
||||
/** 不透明底色(图表容器纯黑 bg-black,取应用面板色);左缘 1px 分隔线与 y 轴线同色 */
|
||||
const COL_BG = '#101014';
|
||||
const COL_BORDER = '#2A2D34';
|
||||
/** 实心条透明度:不透明列内 0.85 足够“实心”,与底色留一丝层次 */
|
||||
const BAR_ALPHA = 0.85;
|
||||
/** 横条高度上限(px):极度放大时单价位占比条不至于铺满大半个竖列 */
|
||||
const BAR_HEIGHT_CAP = 24;
|
||||
/** 统计文字起始 y(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)/ 行高 */
|
||||
const HEADER_TOP = 44;
|
||||
const LINE_H = 13;
|
||||
|
||||
function fmtDate(d?: string): string {
|
||||
return d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '—';
|
||||
}
|
||||
|
||||
/** 右对齐数值超宽时截断加省略号(90%成本 “10.50~14.20” 这类长串的兜底) */
|
||||
function fitText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
|
||||
if (ctx.measureText(text).width <= maxW) return text;
|
||||
let s = text;
|
||||
while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) s = s.slice(0, -1);
|
||||
return `${s}…`;
|
||||
}
|
||||
|
||||
export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, ChipsExt> = {
|
||||
name: CHIPS_NAME,
|
||||
shortName: '筹码',
|
||||
@@ -49,70 +72,60 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
||||
const ext = indicator.extendData;
|
||||
if (!ext) return true;
|
||||
|
||||
// 右上角统计小字块(画线工具栏是 HTML 层,占顶部 ~36px,文字块从 44px 起)
|
||||
const lines: { label: string; value: string; color: string }[] = [];
|
||||
if (ext.error) {
|
||||
lines.push({ label: '筹码', value: ext.error, color: LABEL_COLOR });
|
||||
} else if (ext.rows.length) {
|
||||
const w = ext.winner;
|
||||
lines.push({ label: `筹码 ${fmtDate(ext.date)}`, value: '', color: LABEL_COLOR });
|
||||
lines.push({
|
||||
label: '获利比例',
|
||||
value: w == null ? '—' : `${w.toFixed(2)}%`,
|
||||
color: w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN,
|
||||
});
|
||||
lines.push({ label: '平均成本', value: ext.avg == null ? '—' : ext.avg.toFixed(2), color: AVG_COLOR });
|
||||
lines.push({
|
||||
label: '90%成本',
|
||||
value: ext.costLow == null || ext.costHigh == null ? '—' : `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`,
|
||||
color: VALUE_COLOR,
|
||||
});
|
||||
}
|
||||
if (lines.length) {
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textAlign = 'right';
|
||||
let y = bounding.top + 44;
|
||||
for (const ln of lines) {
|
||||
const text = ln.value ? `${ln.label} ${ln.value}` : ln.label;
|
||||
ctx.fillStyle = ln.color;
|
||||
ctx.fillText(text, bounding.right - 4, y);
|
||||
y += 14;
|
||||
}
|
||||
}
|
||||
if (!ext.rows.length) return true;
|
||||
// 竖列几何:bounding.right 即 y 轴左缘,列贴着价格轴(预留逻辑见 DetailKLine 的 syncRightOffset)
|
||||
const colW = Math.max(COL_MIN_PX, Math.min(CHIPS_COL_PX, Math.floor(bounding.width * COL_MAX_RATIO)));
|
||||
const colRight = bounding.right;
|
||||
const colLeft = colRight - colW;
|
||||
|
||||
// 无数据/出错:不画底色列(空面板不遮图),错误文案画在原右上角位置
|
||||
if (!ext.rows.length) {
|
||||
if (ext.error) {
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = LABEL_COLOR;
|
||||
ctx.fillText(`筹码 ${ext.error}`, bounding.right - 4, bounding.top + HEADER_TOP);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1. 不透明底色列 + 左缘 1px 分隔线(先画,盖住网格/K线/MA/最新价虚线的右端)
|
||||
ctx.fillStyle = COL_BG;
|
||||
ctx.fillRect(colLeft, bounding.top, colW, bounding.height);
|
||||
ctx.fillStyle = COL_BORDER;
|
||||
ctx.fillRect(colLeft, bounding.top, 1, bounding.height);
|
||||
|
||||
const list = chart.getDataList();
|
||||
// 分界价:选中K线的收盘价(父组件随截面注入;缺省退回图表末根收盘),
|
||||
// <=分界价为获利盘(红)、>分界价为套牢盘(蓝)——同花顺同款
|
||||
const list = chart.getDataList();
|
||||
const close = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
|
||||
const split = ext.refClose ?? (list.length ? list[list.length - 1].close : undefined);
|
||||
|
||||
// 2. 筹码横条:列右缘向左伸展,宽度 ∝ 占比/最大占比
|
||||
// 价位升序,便于求相邻像素间距(直方图的“条高”)
|
||||
const rows = [...ext.rows].sort((a, b) => a.price - b.price);
|
||||
const maxP = rows.reduce((m, r) => Math.max(m, r.percent), 0);
|
||||
if (maxP <= 0) return true;
|
||||
const maxW = Math.min(bounding.width * MAX_WIDTH_RATIO, MAX_WIDTH_PX);
|
||||
|
||||
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
|
||||
const pitches: number[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
|
||||
if (p > 0) pitches.push(p);
|
||||
}
|
||||
pitches.sort((a, b) => a - b);
|
||||
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
|
||||
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
|
||||
|
||||
for (const r of rows) {
|
||||
const y = yAxis.convertToPixel(r.price);
|
||||
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
|
||||
const w = Math.max(1, (r.percent / maxP) * maxW);
|
||||
ctx.fillStyle = close == null ? 'rgba(154,160,170,0.5)'
|
||||
: r.price <= close ? 'rgba(254,53,75,0.55)' : 'rgba(47,123,255,0.55)';
|
||||
ctx.fillRect(bounding.right - w, y - barH / 2, w, barH);
|
||||
if (maxP > 0) {
|
||||
// 相邻价位的中位像素间距 → 条高(连续火焰形态;缩到极小时并成实心轮廓)
|
||||
const pitches: number[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const p = Math.abs(yAxis.convertToPixel(rows[i].price) - yAxis.convertToPixel(rows[i - 1].price));
|
||||
if (p > 0) pitches.push(p);
|
||||
}
|
||||
pitches.sort((a, b) => a - b);
|
||||
const pitch = pitches.length ? pitches[pitches.length >> 1] : 4;
|
||||
const barH = Math.min(BAR_HEIGHT_CAP, Math.max(1, pitch * 0.92));
|
||||
const maxBarW = colW - COL_PAD * 2;
|
||||
for (const r of rows) {
|
||||
const y = yAxis.convertToPixel(r.price);
|
||||
if (y < bounding.top - barH || y > bounding.bottom + barH) continue; // 视口外跳过
|
||||
const w = Math.max(1, (r.percent / maxP) * maxBarW);
|
||||
ctx.fillStyle = split == null ? 'rgba(154,160,170,0.85)'
|
||||
: r.price <= split ? `rgba(254,53,75,${BAR_ALPHA})` : `rgba(47,123,255,${BAR_ALPHA})`;
|
||||
ctx.fillRect(colRight - COL_PAD - w, y - barH / 2, w, barH);
|
||||
}
|
||||
}
|
||||
|
||||
// 平均成本虚线(横贯直方图区)
|
||||
// 3. 平均成本虚线(横贯竖列)
|
||||
if (ext.avg != null) {
|
||||
const y = yAxis.convertToPixel(ext.avg);
|
||||
if (y >= bounding.top && y <= bounding.bottom) {
|
||||
@@ -121,12 +134,55 @@ export const chipsIndicator: IndicatorTemplate<Record<string, never>, unknown, C
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bounding.right - maxW, y + 0.5);
|
||||
ctx.lineTo(bounding.right, y + 0.5);
|
||||
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
|
||||
ctx.lineTo(colRight, Math.round(y) + 0.5);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 现价细实线(末根收盘,红/蓝随涨跌,与 y 轴最新价标签同源同色;
|
||||
// 分界价本身已是横条红蓝交界,不再重复画线)
|
||||
if (list.length >= 2) {
|
||||
const last = list[list.length - 1];
|
||||
const y = yAxis.convertToPixel(last.close);
|
||||
if (y >= bounding.top && y <= bounding.bottom) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = last.close >= list[list.length - 2].close ? UP : DOWN;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(colLeft + 1, Math.round(y) + 0.5);
|
||||
ctx.lineTo(colRight, Math.round(y) + 0.5);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 统计小字块:列内顶部,label 左对齐 + value 右对齐,超宽截断
|
||||
//(列宽有限,两列布局比原先的单行右对齐更能放下「90%成本 10.50~14.20」这类长串)
|
||||
const w = ext.winner;
|
||||
const lines: [string, string, string][] = [
|
||||
['筹码', fmtDate(ext.date), VALUE_COLOR],
|
||||
['获利比例', w == null ? '—' : `${w.toFixed(2)}%`, w == null ? VALUE_COLOR : w >= 50 ? UP : DOWN],
|
||||
['平均成本', ext.avg == null ? '—' : ext.avg.toFixed(2), AVG_COLOR],
|
||||
['90%成本', ext.costLow == null || ext.costHigh == null ? '—'
|
||||
: `${ext.costLow.toFixed(2)}~${ext.costHigh.toFixed(2)}`, VALUE_COLOR],
|
||||
];
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
const labelX = colLeft + COL_PAD;
|
||||
const valueX = colRight - COL_PAD;
|
||||
const valueMaxW = colW - COL_PAD * 2 - 36; // 36 ≈ 左侧标签区预留宽
|
||||
let ty = bounding.top + HEADER_TOP;
|
||||
for (const [label, value, color] of lines) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = LABEL_COLOR;
|
||||
ctx.fillText(label, labelX, ty);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(fitText(ctx, value, valueMaxW), valueX, ty);
|
||||
ty += LINE_H;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,5 +19,5 @@ export function registerCustomIndicators(get: ZhixingGetter) {
|
||||
registerIndicator(chipsIndicator);
|
||||
}
|
||||
|
||||
export { CHIPS_NAME, type ChipsExt } from './chips';
|
||||
export { CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from './chips';
|
||||
export { ZHIXING_NAME, ZHIXING_TREND_COLOR, DKX_PERIODS, computeZhixingSeries } from './zhixing';
|
||||
|
||||
@@ -3,36 +3,79 @@ import { ref } from 'vue';
|
||||
import { runScreener } from '@/api/client';
|
||||
import type { ScreenConditions, ScreenerRunResponse } from '@/api/types';
|
||||
|
||||
/** 进度面板的一行痕迹:阶段消息(含耗时),或进度条态 */
|
||||
export interface TraceLine {
|
||||
key: string; // 阶段 key(llm/date/prefilter/bars/filter_done/done)
|
||||
msg: string;
|
||||
ms?: number;
|
||||
}
|
||||
|
||||
export const useScreenerStore = defineStore('screener', () => {
|
||||
const loading = ref(false);
|
||||
const stage = ref<'idle' | 'parsing' | 'screening'>('idle');
|
||||
const note = ref<string | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const result = ref<ScreenerRunResponse | null>(null);
|
||||
const aborted = ref(false); // 本次执行被手动停止(区别于失败)
|
||||
|
||||
// 流式进度状态(run 期间有效;结束后保留到下一次 run 便于复盘)
|
||||
const trace = ref<TraceLine[]>([]);
|
||||
const progress = ref<{ done: number; total: number } | null>(null);
|
||||
const parsed = ref<ScreenConditions | null>(null);
|
||||
|
||||
let ctrl: AbortController | null = null;
|
||||
|
||||
function pushLine(line: TraceLine) {
|
||||
// 同 key 阶段只留最后一行(如重试的 llm)
|
||||
const i = trace.value.findIndex((l) => l.key === line.key);
|
||||
if (i >= 0) trace.value[i] = line;
|
||||
else trace.value.push(line);
|
||||
}
|
||||
|
||||
/** 中断正在执行的筛选(LLM 解析或逐股过滤阶段均可)。 */
|
||||
function abort() {
|
||||
ctrl?.abort();
|
||||
}
|
||||
|
||||
async function run(text: string, conditions?: ScreenConditions | null) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
aborted.value = false;
|
||||
result.value = null;
|
||||
note.value = conditions ? '全市场筛选中…' : 'AI 解析条件中…';
|
||||
stage.value = conditions ? 'screening' : 'parsing';
|
||||
trace.value = [];
|
||||
progress.value = null;
|
||||
parsed.value = conditions ?? null;
|
||||
if (conditions) pushLine({ key: 'direct', msg: '直传条件,跳过 AI 解析' });
|
||||
ctrl = new AbortController();
|
||||
try {
|
||||
// 条件解析与全市场筛选在后端一气呵成;切到筛选阶段给个过渡提示
|
||||
setTimeout(() => {
|
||||
if (loading.value && stage.value === 'parsing') {
|
||||
stage.value = 'screening';
|
||||
note.value = '全市场筛选中…';
|
||||
result.value = await runScreener({ text, conditions: conditions ?? undefined }, (ev) => {
|
||||
switch (ev.type) {
|
||||
case 'stage':
|
||||
pushLine({ key: ev.key, msg: ev.msg, ms: ev.ms });
|
||||
break;
|
||||
case 'parsed':
|
||||
parsed.value = ev.conditions;
|
||||
break;
|
||||
case 'candidates':
|
||||
pushLine({ key: 'candidates', msg: ev.msg ?? `预筛完成:${ev.count} 只候选`, ms: ev.ms });
|
||||
break;
|
||||
case 'progress':
|
||||
progress.value = { done: ev.done, total: ev.total };
|
||||
break;
|
||||
default:
|
||||
break; // result 由 runScreener 返回值落入 result;error 走异常
|
||||
}
|
||||
}, 1200);
|
||||
result.value = await runScreener({ text, conditions: conditions ?? undefined });
|
||||
}, ctrl.signal);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '选股失败';
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
aborted.value = true; // 主动停止,不算失败
|
||||
} else {
|
||||
error.value = e instanceof Error ? e.message : '选股失败';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
stage.value = 'idle';
|
||||
note.value = null;
|
||||
progress.value = null;
|
||||
ctrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, stage, note, error, result, run };
|
||||
return { loading, error, result, aborted, trace, progress, parsed, run, abort };
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useScreenerStore } from '@/stores/screener';
|
||||
import ScreenerForm from '@/components/ScreenerForm.vue';
|
||||
import ConditionChips from '@/components/ConditionChips.vue';
|
||||
@@ -9,20 +9,59 @@ import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
|
||||
const store = useScreenerStore();
|
||||
const previewCode = ref<string | null>(null);
|
||||
const formRef = ref<InstanceType<typeof ScreenerForm> | null>(null);
|
||||
|
||||
const pct = computed(() => {
|
||||
const p = store.progress;
|
||||
if (!p || p.total <= 0) return 0;
|
||||
return Math.min(100, Math.round((p.done / p.total) * 100));
|
||||
});
|
||||
|
||||
function fmtMs(ms?: number): string {
|
||||
return ms == null ? '' : `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" />
|
||||
<ScreenerForm ref="formRef" :loading="store.loading" @run="store.run" @ran="formRef?.refreshHistory()" @stop="store.abort()" />
|
||||
|
||||
<div v-if="store.aborted && !store.loading" class="mt-4 flex items-center gap-2 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#9BA3AE]">
|
||||
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
|
||||
已停止本次筛选(可修改问题后重新开始)
|
||||
</div>
|
||||
|
||||
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-[#9BA3AE]">
|
||||
<svg class="mx-auto mb-3 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>
|
||||
{{ store.note }}
|
||||
<!-- 执行进度面板:加载中实时滚动;失败后保留痕迹便于定位卡在哪一步 -->
|
||||
<div
|
||||
v-if="store.loading || (store.error && store.trace.length)"
|
||||
class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-[#E8EAED]">
|
||||
<svg v-if="store.loading" class="h-4 w-4 shrink-0 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>
|
||||
<span v-else class="h-2 w-2 shrink-0 rounded-full bg-red-500"></span>
|
||||
{{ store.loading ? (store.trace[store.trace.length - 1]?.msg ?? '处理中…') : '执行中断' }}
|
||||
</div>
|
||||
|
||||
<div v-if="store.progress" class="mt-3">
|
||||
<div class="h-1.5 overflow-hidden rounded bg-[#1E2026]">
|
||||
<div class="h-full rounded bg-blue-600 transition-all duration-150" :style="{ width: pct + '%' }"></div>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-[#9BA3AE]">逐股指标过滤 {{ store.progress.done }} / {{ store.progress.total }}({{ pct }}%)</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.parsed && (store.parsed.indicator.length || store.parsed.snapshot.length)" class="mt-3">
|
||||
<ConditionChips :conditions="store.parsed" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-0.5 font-mono text-xs leading-relaxed text-[#9BA3AE]">
|
||||
<div v-for="(l, i) in store.trace" :key="i">
|
||||
<span v-if="l.ms != null" class="mr-2 inline-block w-12 text-right text-[#5F6672]">{{ fmtMs(l.ms) }}</span>{{ l.msg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="store.result">
|
||||
|
||||
Reference in New Issue
Block a user