新增合约配置
This commit is contained in:
parent
1a5ea5b967
commit
63b92550d3
@ -1,7 +1,12 @@
|
||||
import type { IntradayPoint, QuoteData, TradeTick } from '../../types'
|
||||
import { contractConfig } from '../../config/contract'
|
||||
import type { BaiduQuotationResult } from './types'
|
||||
|
||||
export type QuoteFallback = {
|
||||
name: string
|
||||
code: string
|
||||
exchange: string
|
||||
}
|
||||
|
||||
function toNum(value: string | number | undefined | null, fallback = 0): number {
|
||||
if (value == null || value === '' || value === '--') return 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 cur = result.cur
|
||||
const basic = result.basicinfos
|
||||
@ -121,9 +129,9 @@ export function mapBaiduQuotationToQuote(result: BaiduQuotationResult): QuoteDat
|
||||
)
|
||||
|
||||
return {
|
||||
name: basic?.name || contractConfig.name,
|
||||
code: basic?.code || contractConfig.code,
|
||||
exchange: basic?.exchange || contractConfig.exchange,
|
||||
name: basic?.name || fallback.name,
|
||||
code: basic?.code || fallback.code,
|
||||
exchange: basic?.exchange || fallback.exchange,
|
||||
status: update?.stockStatus || '未知',
|
||||
last: toNum(cur?.price ?? op?.currentPrice),
|
||||
change: toNum(cur?.increase),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { contractConfig } from '../../config/contract'
|
||||
import { useContractStore } from '../../stores/contract'
|
||||
import type { BaiduWsItem, BaiduWsMessage, BaiduWsOutbound } from './wsTypes'
|
||||
|
||||
const PING_MS = 6_000
|
||||
@ -26,9 +26,10 @@ export type BaiduQuoteWsHandlers = {
|
||||
}
|
||||
|
||||
function buildItem(): BaiduWsItem {
|
||||
const c = useContractStore().current
|
||||
return {
|
||||
code: contractConfig.code,
|
||||
name: contractConfig.name,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
market: 'ab',
|
||||
financeType: 'futures',
|
||||
}
|
||||
|
||||
@ -19,18 +19,39 @@
|
||||
<span class="time">更新 {{ quote.updatedAt }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<SettingsDialog v-model="settingsVisible" />
|
||||
<ContractSettingsDialog v-model="contractSettingsVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { QuoteData } from '../../types'
|
||||
import type { WsConnectionStatus } from '../../api/baidu/ws'
|
||||
import SettingsDialog from './SettingsDialog.vue'
|
||||
import ContractSettingsDialog from './ContractSettingsDialog.vue'
|
||||
import { useContractStore } from '../../stores/contract'
|
||||
import { useQuotaStore } from '../../stores/quota'
|
||||
|
||||
const props = defineProps<{
|
||||
quote: QuoteData
|
||||
@ -39,6 +60,18 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
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(() =>
|
||||
props.quote.change >= 0 ? 'price-up' : 'price-down',
|
||||
@ -175,5 +208,12 @@ function formatSigned(n: number) {
|
||||
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contract-select {
|
||||
width: 180px;
|
||||
}
|
||||
</style>
|
||||
|
||||
236
src/components/header/ContractSettingsDialog.vue
Normal file
236
src/components/header/ContractSettingsDialog.vue
Normal 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>
|
||||
@ -27,13 +27,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { TooltipComponent } from 'echarts/components'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import VChart from 'vue-echarts'
|
||||
import { contractConfig } from '../../config/contract'
|
||||
import { useContractStore } from '../../stores/contract'
|
||||
import type { QuoteData, TradeTick } from '../../types'
|
||||
|
||||
use([CanvasRenderer, PieChart, TooltipComponent])
|
||||
@ -42,7 +43,8 @@ const props = defineProps<{
|
||||
quote: QuoteData
|
||||
}>()
|
||||
|
||||
const threshold = contractConfig.largeOrderLots
|
||||
const { current } = storeToRefs(useContractStore())
|
||||
const threshold = computed(() => current.value.largeOrderLots)
|
||||
|
||||
const COLORS = {
|
||||
largeBuy: '#f0435c',
|
||||
@ -59,14 +61,14 @@ interface Bucket {
|
||||
pct: string
|
||||
}
|
||||
|
||||
function aggregate(trades: TradeTick[]) {
|
||||
function aggregate(trades: TradeTick[], lots: number) {
|
||||
let largeBuy = 0
|
||||
let largeSell = 0
|
||||
let retailBuy = 0
|
||||
let retailSell = 0
|
||||
|
||||
for (const t of trades) {
|
||||
const isLarge = t.volume > threshold
|
||||
const isLarge = t.volume > lots
|
||||
if (t.side === 'B') {
|
||||
if (isLarge) largeBuy += t.volume
|
||||
else retailBuy += t.volume
|
||||
@ -121,7 +123,7 @@ function toItems(
|
||||
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 sellItems = computed(() => stats.value.sellItems)
|
||||
|
||||
|
||||
@ -4,10 +4,20 @@ import type { AiAdvice } from '../types'
|
||||
import { fetchDeepSeekAdvice } from '../api/deepseek/analyze'
|
||||
import type { AiAnalysisPayload } from '../api/deepseek/types'
|
||||
import { useQuotaStore, type KlinePeriod } from '../stores/quota'
|
||||
import { useContractStore } from '../stores/contract'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { isWsTradingSession } from '../utils/tradingDate'
|
||||
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']
|
||||
|
||||
/** 调度轮询间隔:检查距上次执行是否已超过设定分钟 */
|
||||
@ -31,11 +41,29 @@ export function useAiAdvice() {
|
||||
/** 上次成功执行分析的时间戳(ms) */
|
||||
const lastExecutedAt = ref<number | null>(null)
|
||||
const quota = useQuotaStore()
|
||||
const contractStore = useContractStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
useTitleBlink(data)
|
||||
|
||||
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 线,失败不阻断主流程 */
|
||||
async function ensureKlines() {
|
||||
@ -66,30 +94,37 @@ export function useAiAdvice() {
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
const epoch = analysisEpoch
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await quota.fetchForAnalysis()
|
||||
if (epoch !== analysisEpoch) return
|
||||
await ensureKlines()
|
||||
if (epoch !== analysisEpoch) return
|
||||
|
||||
const payload: AiAnalysisPayload = {
|
||||
keywords: settings.keywords,
|
||||
keywords: mergeKeywords(settings.keywords, contractStore.current.keywords),
|
||||
snapshot: quota.getAnalysisSnapshot(),
|
||||
}
|
||||
|
||||
console.log('[AI] 提交给 DeepSeek 的关键词', payload.keywords)
|
||||
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()
|
||||
if (!silent) ElMessage.success('AI 建议已更新')
|
||||
} catch (e) {
|
||||
if (epoch !== analysisEpoch) return
|
||||
error.value = e
|
||||
const msg = formatError(e)
|
||||
console.error('[AI] 一键获取建议失败', e)
|
||||
if (!silent) ElMessage.error(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (epoch === analysisEpoch) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,5 @@
|
||||
/** 当前关注的期货合约(后续可改为可切换) */
|
||||
export const contractConfig = {
|
||||
/** 合约代码,对应百度行情 / 同花顺持仓接口 code/contract */
|
||||
code: 'FG609',
|
||||
/** 品种代码,持仓接口 variety;缺省可从 code 字母前缀推导 */
|
||||
variety: 'FG',
|
||||
/** 展示名称;接口未返回 name 时使用 */
|
||||
name: '玻璃2609',
|
||||
/** 交易所展示名 */
|
||||
exchange: '郑商所',
|
||||
/**
|
||||
* 大单手数阈值:现手大于该值计为大单,否则计为散单。
|
||||
* 用于分时成交「大单分析」饼图。
|
||||
/**
|
||||
* @deprecated 合约配置已迁至 localStorage(stores/contract.ts)。
|
||||
* 此处仅保留默认种子,供首次安装与兼容引用。
|
||||
*/
|
||||
largeOrderLots: 100,
|
||||
} as const
|
||||
export { DEFAULT_CONTRACT } from '../stores/contract'
|
||||
|
||||
133
src/stores/contract.ts
Normal file
133
src/stores/contract.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, toRaw } from 'vue'
|
||||
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 { getFuturesNews } from '../api/baidu/news'
|
||||
import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote'
|
||||
@ -52,10 +52,11 @@ function emptyCandles(): QuoteData['candles'] {
|
||||
}
|
||||
|
||||
function emptyQuote(): QuoteData {
|
||||
const c = useContractStore().current
|
||||
return {
|
||||
name: contractConfig.name,
|
||||
code: contractConfig.code,
|
||||
exchange: contractConfig.exchange,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
exchange: c.exchange,
|
||||
status: '',
|
||||
last: 0,
|
||||
change: 0,
|
||||
@ -98,6 +99,10 @@ function emptyPositions(): PositionsData {
|
||||
}
|
||||
}
|
||||
|
||||
function activeContract() {
|
||||
return useContractStore().current
|
||||
}
|
||||
|
||||
export const useQuotaStore = defineStore('quota', () => {
|
||||
// ─── 行情 ───────────────────────────────────────────────
|
||||
const quote = ref<QuoteData>(emptyQuote())
|
||||
@ -129,7 +134,8 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
}
|
||||
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
|
||||
if (product === 'tick') {
|
||||
quote.value = applyWsTick(quote.value, msg.data as BaiduWsTickData)
|
||||
@ -224,11 +230,16 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
)
|
||||
|
||||
async function fetchQuote() {
|
||||
const c = activeContract()
|
||||
quoteLoading.value = true
|
||||
quoteError.value = null
|
||||
try {
|
||||
const result = await getStockQuotation({ code: contractConfig.code })
|
||||
const mapped = mapBaiduQuotationToQuote(result)
|
||||
const result = await getStockQuotation({ code: c.code })
|
||||
const mapped = mapBaiduQuotationToQuote(result, {
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
exchange: c.exchange,
|
||||
})
|
||||
mapped.candles = emptyCandles()
|
||||
loadedKlines.value = { day: false, week: false, month: false }
|
||||
quote.value = mapped
|
||||
@ -248,11 +259,12 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
return quote.value.candles[period]
|
||||
}
|
||||
|
||||
const c = activeContract()
|
||||
klineLoading.value = true
|
||||
quoteError.value = null
|
||||
try {
|
||||
const result = await getStockKline({
|
||||
code: contractConfig.code,
|
||||
code: c.code,
|
||||
ktype: KTYPE_MAP[period],
|
||||
})
|
||||
const candles = mapBaiduKlineToCandles(result)
|
||||
@ -269,10 +281,11 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
}
|
||||
|
||||
async function fetchNews() {
|
||||
const c = activeContract()
|
||||
newsLoading.value = true
|
||||
newsError.value = null
|
||||
try {
|
||||
const list = await getFuturesNews({ code: contractConfig.code })
|
||||
const list = await getFuturesNews({ code: c.code })
|
||||
news.value = mapBaiduNewsToItems(list)
|
||||
newsLoaded.value = news.value.length > 0
|
||||
} catch (e) {
|
||||
@ -293,8 +306,9 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
positionsLoading.value = true
|
||||
positionsError.value = null
|
||||
try {
|
||||
const contract = contractConfig.code
|
||||
const variety = contractConfig.variety || extractVariety(contract)
|
||||
const c = activeContract()
|
||||
const contract = c.code
|
||||
const variety = c.variety || extractVariety(contract)
|
||||
let date = getPositionQueryDate()
|
||||
let mapped: PositionsData | null = null
|
||||
|
||||
@ -337,6 +351,27 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
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 分析前取数:行情每次重新拉取;
|
||||
* 新闻 / 机构持仓若已有成功数据则跳过,避免重复请求。
|
||||
@ -361,11 +396,12 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
* 返回深拷贝,避免分析过程中被行情刷新污染。
|
||||
*/
|
||||
function getAnalysisSnapshot(): AnalysisSnapshot {
|
||||
const c = activeContract()
|
||||
return {
|
||||
contract: {
|
||||
code: quote.value.code || contractConfig.code,
|
||||
name: quote.value.name || contractConfig.name,
|
||||
exchange: quote.value.exchange || contractConfig.exchange,
|
||||
code: quote.value.code || c.code,
|
||||
name: quote.value.name || c.name,
|
||||
exchange: quote.value.exchange || c.exchange,
|
||||
},
|
||||
quote: quote.value ? clonePlain(quote.value) : null,
|
||||
news: clonePlain(news.value),
|
||||
@ -402,6 +438,7 @@ export const useQuotaStore = defineStore('quota', () => {
|
||||
fetchPositions,
|
||||
fetchAll,
|
||||
fetchForAnalysis,
|
||||
reloadForContract,
|
||||
getAnalysisSnapshot,
|
||||
enableWs,
|
||||
connectWs,
|
||||
|
||||
@ -144,3 +144,32 @@ export interface AppSettings {
|
||||
/** 定时分析间隔(分钟) */
|
||||
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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user