新增合约配置

This commit is contained in:
dongzp 2026-07-22 18:02:21 +08:00
parent 1a5ea5b967
commit 63b92550d3
10 changed files with 557 additions and 47 deletions

View File

@ -1,7 +1,12 @@
import type { IntradayPoint, QuoteData, TradeTick } from '../../types' import type { IntradayPoint, QuoteData, TradeTick } from '../../types'
import { contractConfig } from '../../config/contract'
import type { BaiduQuotationResult } from './types' import type { BaiduQuotationResult } from './types'
export type QuoteFallback = {
name: string
code: string
exchange: string
}
function toNum(value: string | number | undefined | null, fallback = 0): number { function toNum(value: string | number | undefined | null, fallback = 0): number {
if (value == null || value === '' || value === '--') return fallback if (value == null || value === '' || value === '--') return fallback
if (typeof value === 'number') return Number.isFinite(value) ? value : fallback if (typeof value === 'number') return Number.isFinite(value) ? value : fallback
@ -75,7 +80,10 @@ function parseTrades(result: BaiduQuotationResult): TradeTick[] {
})) }))
} }
export function mapBaiduQuotationToQuote(result: BaiduQuotationResult): QuoteData { export function mapBaiduQuotationToQuote(
result: BaiduQuotationResult,
fallback: QuoteFallback,
): QuoteData {
const op = result.pankouinfos?.origin_pankou const op = result.pankouinfos?.origin_pankou
const cur = result.cur const cur = result.cur
const basic = result.basicinfos const basic = result.basicinfos
@ -121,9 +129,9 @@ export function mapBaiduQuotationToQuote(result: BaiduQuotationResult): QuoteDat
) )
return { return {
name: basic?.name || contractConfig.name, name: basic?.name || fallback.name,
code: basic?.code || contractConfig.code, code: basic?.code || fallback.code,
exchange: basic?.exchange || contractConfig.exchange, exchange: basic?.exchange || fallback.exchange,
status: update?.stockStatus || '未知', status: update?.stockStatus || '未知',
last: toNum(cur?.price ?? op?.currentPrice), last: toNum(cur?.price ?? op?.currentPrice),
change: toNum(cur?.increase), change: toNum(cur?.increase),

View File

@ -1,4 +1,4 @@
import { contractConfig } from '../../config/contract' import { useContractStore } from '../../stores/contract'
import type { BaiduWsItem, BaiduWsMessage, BaiduWsOutbound } from './wsTypes' import type { BaiduWsItem, BaiduWsMessage, BaiduWsOutbound } from './wsTypes'
const PING_MS = 6_000 const PING_MS = 6_000
@ -26,9 +26,10 @@ export type BaiduQuoteWsHandlers = {
} }
function buildItem(): BaiduWsItem { function buildItem(): BaiduWsItem {
const c = useContractStore().current
return { return {
code: contractConfig.code, code: c.code,
name: contractConfig.name, name: c.name,
market: 'ab', market: 'ab',
financeType: 'futures', financeType: 'futures',
} }

View File

@ -19,18 +19,39 @@
<span class="time">更新 {{ quote.updatedAt }}</span> <span class="time">更新 {{ quote.updatedAt }}</span>
</div> </div>
<div class="actions"> <div class="actions">
<el-button :icon="Setting" circle title="设置" @click="settingsVisible = true" /> <el-button :icon="Setting" circle title="AI 设置" @click="settingsVisible = true" />
<el-select
class="contract-select"
:model-value="selectedId"
placeholder="选择合约"
size="default"
@change="onContractChange"
>
<el-option
v-for="c in contracts"
:key="c.id"
:label="`${c.name} (${c.code})`"
:value="c.id"
/>
</el-select>
<el-button title="合约设置" @click="contractSettingsVisible = true">合约</el-button>
</div> </div>
<SettingsDialog v-model="settingsVisible" /> <SettingsDialog v-model="settingsVisible" />
<ContractSettingsDialog v-model="contractSettingsVisible" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { Setting } from '@element-plus/icons-vue' import { Setting } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { QuoteData } from '../../types' import type { QuoteData } from '../../types'
import type { WsConnectionStatus } from '../../api/baidu/ws' import type { WsConnectionStatus } from '../../api/baidu/ws'
import SettingsDialog from './SettingsDialog.vue' import SettingsDialog from './SettingsDialog.vue'
import ContractSettingsDialog from './ContractSettingsDialog.vue'
import { useContractStore } from '../../stores/contract'
import { useQuotaStore } from '../../stores/quota'
const props = defineProps<{ const props = defineProps<{
quote: QuoteData quote: QuoteData
@ -39,6 +60,18 @@ const props = defineProps<{
}>() }>()
const settingsVisible = ref(false) const settingsVisible = ref(false)
const contractSettingsVisible = ref(false)
const contractStore = useContractStore()
const quota = useQuotaStore()
const { contracts, selectedId } = storeToRefs(contractStore)
async function onContractChange(id: string) {
if (!id || id === selectedId.value) return
contractStore.select(id)
await quota.reloadForContract()
ElMessage.success('已切换合约')
}
const priceClass = computed(() => const priceClass = computed(() =>
props.quote.change >= 0 ? 'price-up' : 'price-down', props.quote.change >= 0 ? 'price-up' : 'price-down',
@ -175,5 +208,12 @@ function formatSigned(n: number) {
.actions { .actions {
margin-left: auto; margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.contract-select {
width: 180px;
} }
</style> </style>

View File

@ -0,0 +1,236 @@
<template>
<el-dialog
:model-value="modelValue"
title="合约设置"
width="760px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div class="toolbar">
<el-button type="primary" @click="startAdd">新增合约</el-button>
</div>
<el-table :data="contracts" size="small" stripe empty-text="暂无合约">
<el-table-column label="当前" width="64" align="center">
<template #default="{ row }">
<el-radio
:model-value="selectedId"
:value="row.id"
@change="onSelect(row.id)"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="100" />
<el-table-column prop="code" label="代码" width="90" />
<el-table-column prop="variety" label="品种" width="70" />
<el-table-column prop="exchange" label="交易所" width="90" />
<el-table-column prop="largeOrderLots" label="大单阈值" width="88" />
<el-table-column prop="keywords" label="关键字" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="startEdit(row)">编辑</el-button>
<el-button
link
type="danger"
:disabled="contracts.length <= 1"
@click="onRemove(row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="formVisible"
:title="editingId ? '编辑合约' : '新增合约'"
width="420px"
append-to-body
destroy-on-close
>
<el-form label-position="top">
<el-form-item label="合约代码" required>
<el-input
v-model="form.code"
placeholder="如 FG609"
@blur="onCodeBlur"
/>
</el-form-item>
<el-form-item label="展示名称" required>
<el-input v-model="form.name" placeholder="如 玻璃2609" />
</el-form-item>
<el-form-item label="品种代码">
<el-input v-model="form.variety" placeholder="缺省从代码前缀推导" />
</el-form-item>
<el-form-item label="交易所">
<el-input v-model="form.exchange" placeholder="如 郑商所" />
</el-form-item>
<el-form-item label="大单手数阈值">
<el-input-number
v-model="form.largeOrderLots"
:min="1"
:max="999999"
:step="10"
controls-position="right"
/>
</el-form-item>
<el-form-item label="合约关键字">
<el-input
v-model="form.keywords"
type="textarea"
:rows="3"
placeholder="如:当前持仓、止损位、关注看点;AI 分析时追加在全局「额外关键字」之后"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="saveForm">保存</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useContractStore, DEFAULT_CONTRACT } from '../../stores/contract'
import { useQuotaStore } from '../../stores/quota'
import { extractVariety } from '../../utils/tradingDate'
import type { ContractItem } from '../../types'
defineProps<{
modelValue?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const contractStore = useContractStore()
const quota = useQuotaStore()
const { contracts, selectedId } = storeToRefs(contractStore)
const formVisible = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({
code: '',
name: '',
variety: '',
exchange: '',
largeOrderLots: DEFAULT_CONTRACT.largeOrderLots,
keywords: '',
})
function resetForm(seed?: Partial<ContractItem>) {
form.code = seed?.code || ''
form.name = seed?.name || ''
form.variety = seed?.variety || ''
form.exchange = seed?.exchange || ''
form.largeOrderLots = seed?.largeOrderLots || DEFAULT_CONTRACT.largeOrderLots
form.keywords = seed?.keywords || ''
}
function startAdd() {
editingId.value = null
resetForm({
exchange: DEFAULT_CONTRACT.exchange,
largeOrderLots: DEFAULT_CONTRACT.largeOrderLots,
})
formVisible.value = true
}
function startEdit(row: ContractItem) {
editingId.value = row.id
resetForm(row)
formVisible.value = true
}
function onCodeBlur() {
const code = form.code.trim().toUpperCase()
form.code = code
if (!form.variety.trim() && code) {
form.variety = extractVariety(code)
}
if (!form.name.trim() && code) {
form.name = code
}
}
async function onSelect(id: string) {
if (id === selectedId.value) return
contractStore.select(id)
await quota.reloadForContract()
ElMessage.success('已切换合约')
}
async function onRemove(id: string) {
try {
await ElMessageBox.confirm('确定删除该合约?', '提示', { type: 'warning' })
} catch {
return
}
const wasSelected = id === selectedId.value
if (!contractStore.remove(id)) {
ElMessage.warning('至少保留一个合约')
return
}
ElMessage.success('已删除')
if (wasSelected) {
await quota.reloadForContract()
}
}
function saveForm() {
const payload = {
code: form.code.trim(),
name: form.name.trim(),
variety: form.variety.trim(),
exchange: form.exchange.trim(),
largeOrderLots: form.largeOrderLots,
keywords: form.keywords.trim(),
}
if (!payload.code) {
ElMessage.warning('请填写合约代码')
return
}
if (!payload.name) {
ElMessage.warning('请填写展示名称')
return
}
if (editingId.value) {
const id = editingId.value
const prev = contracts.value.find((c) => c.id === id)
const wasCurrent = id === selectedId.value
const identityChanged =
!!prev &&
(prev.code !== payload.code.toUpperCase() ||
(prev.variety || '') !== (payload.variety.toUpperCase() || prev.variety))
if (!contractStore.update(id, payload)) {
ElMessage.error('保存失败')
return
}
formVisible.value = false
ElMessage.success('已更新')
if (wasCurrent && identityChanged) {
void quota.reloadForContract()
}
return
}
const item = contractStore.add(payload)
if (!item) {
ElMessage.error('保存失败')
return
}
formVisible.value = false
ElMessage.success('已新增')
}
</script>
<style scoped>
.toolbar {
margin-bottom: 12px;
}
</style>

View File

@ -27,13 +27,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts' import { PieChart } from 'echarts/charts'
import { TooltipComponent } from 'echarts/components' import { TooltipComponent } from 'echarts/components'
import type { EChartsOption } from 'echarts' import type { EChartsOption } from 'echarts'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import { contractConfig } from '../../config/contract' import { useContractStore } from '../../stores/contract'
import type { QuoteData, TradeTick } from '../../types' import type { QuoteData, TradeTick } from '../../types'
use([CanvasRenderer, PieChart, TooltipComponent]) use([CanvasRenderer, PieChart, TooltipComponent])
@ -42,7 +43,8 @@ const props = defineProps<{
quote: QuoteData quote: QuoteData
}>() }>()
const threshold = contractConfig.largeOrderLots const { current } = storeToRefs(useContractStore())
const threshold = computed(() => current.value.largeOrderLots)
const COLORS = { const COLORS = {
largeBuy: '#f0435c', largeBuy: '#f0435c',
@ -59,14 +61,14 @@ interface Bucket {
pct: string pct: string
} }
function aggregate(trades: TradeTick[]) { function aggregate(trades: TradeTick[], lots: number) {
let largeBuy = 0 let largeBuy = 0
let largeSell = 0 let largeSell = 0
let retailBuy = 0 let retailBuy = 0
let retailSell = 0 let retailSell = 0
for (const t of trades) { for (const t of trades) {
const isLarge = t.volume > threshold const isLarge = t.volume > lots
if (t.side === 'B') { if (t.side === 'B') {
if (isLarge) largeBuy += t.volume if (isLarge) largeBuy += t.volume
else retailBuy += t.volume else retailBuy += t.volume
@ -121,7 +123,7 @@ function toItems(
return { buyItems, sellItems, data: [...buyItems, ...sellItems] } return { buyItems, sellItems, data: [...buyItems, ...sellItems] }
} }
const stats = computed(() => toItems(aggregate(props.quote.trades))) const stats = computed(() => toItems(aggregate(props.quote.trades, threshold.value)))
const buyItems = computed(() => stats.value.buyItems) const buyItems = computed(() => stats.value.buyItems)
const sellItems = computed(() => stats.value.sellItems) const sellItems = computed(() => stats.value.sellItems)

View File

@ -4,10 +4,20 @@ import type { AiAdvice } from '../types'
import { fetchDeepSeekAdvice } from '../api/deepseek/analyze' import { fetchDeepSeekAdvice } from '../api/deepseek/analyze'
import type { AiAnalysisPayload } from '../api/deepseek/types' import type { AiAnalysisPayload } from '../api/deepseek/types'
import { useQuotaStore, type KlinePeriod } from '../stores/quota' import { useQuotaStore, type KlinePeriod } from '../stores/quota'
import { useContractStore } from '../stores/contract'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { isWsTradingSession } from '../utils/tradingDate' import { isWsTradingSession } from '../utils/tradingDate'
import { useTitleBlink } from './useTitleBlink' import { useTitleBlink } from './useTitleBlink'
/** 全局额外关键字 + 当前合约关键字(合约段追加在后) */
function mergeKeywords(globalKeywords: string, contractKeywords: string): string {
const global = globalKeywords.trim()
const extra = contractKeywords.trim()
if (!extra) return global
if (!global) return extra
return `${global}\n${extra}`
}
const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month'] const KLINE_PERIODS: KlinePeriod[] = ['day', 'week', 'month']
/** 调度轮询间隔:检查距上次执行是否已超过设定分钟 */ /** 调度轮询间隔:检查距上次执行是否已超过设定分钟 */
@ -31,11 +41,29 @@ export function useAiAdvice() {
/** 上次成功执行分析的时间戳(ms) */ /** 上次成功执行分析的时间戳(ms) */
const lastExecutedAt = ref<number | null>(null) const lastExecutedAt = ref<number | null>(null)
const quota = useQuotaStore() const quota = useQuotaStore()
const contractStore = useContractStore()
const settings = useSettingsStore() const settings = useSettingsStore()
useTitleBlink(data) useTitleBlink(data)
let scheduleTimer: ReturnType<typeof setInterval> | null = null let scheduleTimer: ReturnType<typeof setInterval> | null = null
/** 切换合约时递增,丢弃进行中的旧分析结果 */
let analysisEpoch = 0
function clearAdvice() {
data.value = emptyAdvice()
error.value = null
lastExecutedAt.value = null
}
watch(
() => contractStore.selectedId,
() => {
analysisEpoch += 1
loading.value = false
clearAdvice()
},
)
/** 尽量补全日/周/月 K 线,失败不阻断主流程 */ /** 尽量补全日/周/月 K 线,失败不阻断主流程 */
async function ensureKlines() { async function ensureKlines() {
@ -66,30 +94,37 @@ export function useAiAdvice() {
} }
if (loading.value) return if (loading.value) return
const epoch = analysisEpoch
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
await quota.fetchForAnalysis() await quota.fetchForAnalysis()
if (epoch !== analysisEpoch) return
await ensureKlines() await ensureKlines()
if (epoch !== analysisEpoch) return
const payload: AiAnalysisPayload = { const payload: AiAnalysisPayload = {
keywords: settings.keywords, keywords: mergeKeywords(settings.keywords, contractStore.current.keywords),
snapshot: quota.getAnalysisSnapshot(), snapshot: quota.getAnalysisSnapshot(),
} }
console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords) console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords)
console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot) console.log('[AI] 提交给 DeepSeek 的数据快照', payload.snapshot)
data.value = await fetchDeepSeekAdvice(apiKey, payload) const advice = await fetchDeepSeekAdvice(apiKey, payload)
if (epoch !== analysisEpoch) return
data.value = advice
lastExecutedAt.value = Date.now() lastExecutedAt.value = Date.now()
if (!silent) ElMessage.success('AI 建议已更新') if (!silent) ElMessage.success('AI 建议已更新')
} catch (e) { } catch (e) {
if (epoch !== analysisEpoch) return
error.value = e error.value = e
const msg = formatError(e) const msg = formatError(e)
console.error('[AI] 一键获取建议失败', e) console.error('[AI] 一键获取建议失败', e)
if (!silent) ElMessage.error(msg) if (!silent) ElMessage.error(msg)
} finally { } finally {
loading.value = false if (epoch === analysisEpoch) loading.value = false
} }
} }

View File

@ -1,16 +1,5 @@
/** 当前关注的期货合约(后续可改为可切换) */ /**
export const contractConfig = { * @deprecated 合约配置已迁至 localStorage(stores/contract.ts)。
/** 合约代码,对应百度行情 / 同花顺持仓接口 code/contract */ * 此处仅保留默认种子,供首次安装与兼容引用。
code: 'FG609', */
/** 品种代码,持仓接口 variety;缺省可从 code 字母前缀推导 */ export { DEFAULT_CONTRACT } from '../stores/contract'
variety: 'FG',
/** 展示名称;接口未返回 name 时使用 */
name: '玻璃2609',
/** 交易所展示名 */
exchange: '郑商所',
/**
* 大单手数阈值:现手大于该值计为大单,否则计为散单。
* 用于分时成交「大单分析」饼图。
*/
largeOrderLots: 100,
} as const

133
src/stores/contract.ts Normal file
View File

@ -0,0 +1,133 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import type { ContractItem, ContractStorage } from '../types'
import { extractVariety } from '../utils/tradingDate'
const STORAGE_KEY = 'ai-trade-contracts'
/** 首次安装时的默认合约(原 config/contract.ts) */
export const DEFAULT_CONTRACT: Omit<ContractItem, 'id'> = {
code: 'FG609',
variety: 'FG',
name: '玻璃2609',
exchange: '郑商所',
largeOrderLots: 100,
keywords: '',
}
function createId(): string {
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
}
function normalizeItem(raw: Partial<ContractItem> & { code?: string }): ContractItem | null {
const code = String(raw.code || '').trim().toUpperCase()
if (!code) return null
const variety = String(raw.variety || '').trim().toUpperCase() || extractVariety(code)
const name = String(raw.name || '').trim() || code
const exchange = String(raw.exchange || '').trim() || ''
const lots = Number(raw.largeOrderLots)
const largeOrderLots =
Number.isFinite(lots) && lots > 0 ? Math.floor(lots) : DEFAULT_CONTRACT.largeOrderLots
const keywords = String(raw.keywords ?? '').trim()
return {
id: String(raw.id || createId()),
code,
variety,
name,
exchange,
largeOrderLots,
keywords,
}
}
function load(): ContractStorage {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') as Partial<ContractStorage>
const list = Array.isArray(parsed.contracts)
? parsed.contracts.map((c) => normalizeItem(c)).filter((c): c is ContractItem => c != null)
: []
if (list.length === 0) {
const first = normalizeItem({ ...DEFAULT_CONTRACT, id: createId() })!
return { contracts: [first], selectedId: first.id }
}
const selectedId =
typeof parsed.selectedId === 'string' && list.some((c) => c.id === parsed.selectedId)
? parsed.selectedId
: list[0].id
return { contracts: list, selectedId }
} catch {
const first = normalizeItem({ ...DEFAULT_CONTRACT, id: createId() })!
return { contracts: [first], selectedId: first.id }
}
}
export const useContractStore = defineStore('contract', () => {
const saved = load()
const contracts = ref<ContractItem[]>(saved.contracts)
const selectedId = ref(saved.selectedId)
const current = computed(() => {
const found = contracts.value.find((c) => c.id === selectedId.value)
return found ?? contracts.value[0]
})
watch(
[contracts, selectedId],
() => {
// 选中项失效时回落到第一项
if (!contracts.value.some((c) => c.id === selectedId.value) && contracts.value[0]) {
selectedId.value = contracts.value[0].id
}
const payload: ContractStorage = {
contracts: contracts.value,
selectedId: selectedId.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
},
{ deep: true },
)
function select(id: string) {
if (!contracts.value.some((c) => c.id === id)) return
selectedId.value = id
}
function add(input: Omit<ContractItem, 'id'>): ContractItem | null {
const item = normalizeItem({ ...input, id: createId() })
if (!item) return null
contracts.value = [...contracts.value, item]
return item
}
function update(id: string, input: Partial<Omit<ContractItem, 'id'>>): boolean {
const idx = contracts.value.findIndex((c) => c.id === id)
if (idx < 0) return false
const merged = normalizeItem({ ...contracts.value[idx], ...input, id })
if (!merged) return false
const next = contracts.value.slice()
next[idx] = merged
contracts.value = next
return true
}
function remove(id: string): boolean {
if (contracts.value.length <= 1) return false
const next = contracts.value.filter((c) => c.id !== id)
if (next.length === contracts.value.length) return false
contracts.value = next
if (selectedId.value === id) {
selectedId.value = next[0].id
}
return true
}
return {
contracts,
selectedId,
current,
select,
add,
update,
remove,
}
})

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref, toRaw } from 'vue' import { computed, ref, toRaw } from 'vue'
import type { Candle, NewsItem, PositionsData, QuoteData } from '../types' import type { Candle, NewsItem, PositionsData, QuoteData } from '../types'
import { contractConfig } from '../config/contract' import { useContractStore } from './contract'
import { getStockKline, getStockQuotation } from '../api/baidu/quotation' import { getStockKline, getStockQuotation } from '../api/baidu/quotation'
import { getFuturesNews } from '../api/baidu/news' import { getFuturesNews } from '../api/baidu/news'
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote' import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
@ -52,10 +52,11 @@ function emptyCandles(): QuoteData['candles'] {
} }
function emptyQuote(): QuoteData { function emptyQuote(): QuoteData {
const c = useContractStore().current
return { return {
name: contractConfig.name, name: c.name,
code: contractConfig.code, code: c.code,
exchange: contractConfig.exchange, exchange: c.exchange,
status: '', status: '',
last: 0, last: 0,
change: 0, change: 0,
@ -98,6 +99,10 @@ function emptyPositions(): PositionsData {
} }
} }
function activeContract() {
return useContractStore().current
}
export const useQuotaStore = defineStore('quota', () => { export const useQuotaStore = defineStore('quota', () => {
// ─── 行情 ─────────────────────────────────────────────── // ─── 行情 ───────────────────────────────────────────────
const quote = ref<QuoteData>(emptyQuote()) const quote = ref<QuoteData>(emptyQuote())
@ -129,7 +134,8 @@ export const useQuotaStore = defineStore('quota', () => {
} }
return return
} }
if (msg.data.code && msg.data.code !== contractConfig.code) return const code = activeContract().code
if (msg.data.code && msg.data.code !== code) return
const product = msg.data.product const product = msg.data.product
if (product === 'tick') { if (product === 'tick') {
quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData) quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData)
@ -224,11 +230,16 @@ export const useQuotaStore = defineStore('quota', () => {
) )
async function fetchQuote() { async function fetchQuote() {
const c = activeContract()
quoteLoading.value = true quoteLoading.value = true
quoteError.value = null quoteError.value = null
try { try {
const result = await getStockQuotation({ code: contractConfig.code }) const result = await getStockQuotation({ code: c.code })
const mapped = mapBaiduQuotationToQuote(result) const mapped = mapBaiduQuotationToQuote(result, {
name: c.name,
code: c.code,
exchange: c.exchange,
})
mapped.candles = emptyCandles() mapped.candles = emptyCandles()
loadedKlines.value = { day: false, week: false, month: false } loadedKlines.value = { day: false, week: false, month: false }
quote.value = mapped quote.value = mapped
@ -248,11 +259,12 @@ export const useQuotaStore = defineStore('quota', () => {
return quote.value.candles[period] return quote.value.candles[period]
} }
const c = activeContract()
klineLoading.value = true klineLoading.value = true
quoteError.value = null quoteError.value = null
try { try {
const result = await getStockKline({ const result = await getStockKline({
code: contractConfig.code, code: c.code,
ktype: KTYPE_MAP[period], ktype: KTYPE_MAP[period],
}) })
const candles = mapBaiduKlineToCandles(result) const candles = mapBaiduKlineToCandles(result)
@ -269,10 +281,11 @@ export const useQuotaStore = defineStore('quota', () => {
} }
async function fetchNews() { async function fetchNews() {
const c = activeContract()
newsLoading.value = true newsLoading.value = true
newsError.value = null newsError.value = null
try { try {
const list = await getFuturesNews({ code: contractConfig.code }) const list = await getFuturesNews({ code: c.code })
news.value = mapBaiduNewsToItems(list) news.value = mapBaiduNewsToItems(list)
newsLoaded.value = news.value.length > 0 newsLoaded.value = news.value.length > 0
} catch (e) { } catch (e) {
@ -293,8 +306,9 @@ export const useQuotaStore = defineStore('quota', () => {
positionsLoading.value = true positionsLoading.value = true
positionsError.value = null positionsError.value = null
try { try {
const contract = contractConfig.code const c = activeContract()
const variety = contractConfig.variety || extractVariety(contract) const contract = c.code
const variety = c.variety || extractVariety(contract)
let date = getPositionQueryDate() let date = getPositionQueryDate()
let mapped: PositionsData | null = null let mapped: PositionsData | null = null
@ -337,6 +351,27 @@ export const useQuotaStore = defineStore('quota', () => {
await Promise.all([fetchQuote(), fetchNews(), fetchPositions()]) await Promise.all([fetchQuote(), fetchNews(), fetchPositions()])
} }
/**
* 切换合约后:断开旧 WS、清空缓存、重新拉取并按需重连。
*/
async function reloadForContract() {
quoteWs?.disconnect()
quoteWs = null
wsStatus.value = 'disconnected'
quote.value = emptyQuote()
news.value = []
positions.value = emptyPositions()
loadedKlines.value = { day: false, week: false, month: false }
newsLoaded.value = false
positionsLoaded.value = false
quoteError.value = null
newsError.value = null
positionsError.value = null
await fetchAll()
}
/** /**
* AI 分析前取数:行情每次重新拉取; * AI 分析前取数:行情每次重新拉取;
* 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。 * 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。
@ -361,11 +396,12 @@ export const useQuotaStore = defineStore('quota', () => {
* 返回深拷贝,避免分析过程中被行情刷新污染。 * 返回深拷贝,避免分析过程中被行情刷新污染。
*/ */
function getAnalysisSnapshot(): AnalysisSnapshot { function getAnalysisSnapshot(): AnalysisSnapshot {
const c = activeContract()
return { return {
contract: { contract: {
code: quote.value.code || contractConfig.code, code: quote.value.code || c.code,
name: quote.value.name || contractConfig.name, name: quote.value.name || c.name,
exchange: quote.value.exchange || contractConfig.exchange, exchange: quote.value.exchange || c.exchange,
}, },
quote: quote.value ? clonePlain(quote.value) : null, quote: quote.value ? clonePlain(quote.value) : null,
news: clonePlain(news.value), news: clonePlain(news.value),
@ -402,6 +438,7 @@ export const useQuotaStore = defineStore('quota', () => {
fetchPositions, fetchPositions,
fetchAll, fetchAll,
fetchForAnalysis, fetchForAnalysis,
reloadForContract,
getAnalysisSnapshot, getAnalysisSnapshot,
enableWs, enableWs,
connectWs, connectWs,

View File

@ -144,3 +144,32 @@ export interface AppSettings {
/** 定时分析间隔(分钟) */ /** 定时分析间隔(分钟) */
scheduledAnalysisMinutes: number scheduledAnalysisMinutes: number
} }
/** 单个期货合约配置(可多份,存 localStorage) */
export interface ContractItem {
id: string
/** 合约代码,对应百度行情 / 同花顺持仓接口 code/contract */
code: string
/** 品种代码,持仓接口 variety;缺省可从 code 字母前缀推导 */
variety: string
/** 展示名称;接口未返回 name 时使用 */
name: string
/** 交易所展示名 */
exchange: string
/**
* 大单手数阈值:现手大于该值计为大单,否则计为散单。
* 用于分时成交「大单分析」饼图。
*/
largeOrderLots: number
/**
* 合约专属关键字(持仓/止损/看点等)。
* AI 分析时追加在全局「额外关键字」之后。
*/
keywords: string
}
/** localStorage 中的合约列表与当前选中 */
export interface ContractStorage {
contracts: ContractItem[]
selectedId: string
}