From 249be8d8825d130c433cf7c03e193c5a396c5bd9 Mon Sep 17 00:00:00 2001 From: dongzp <975303544@qq.com> Date: Tue, 21 Jul 2026 16:14:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=81=E4=BB=93=E6=95=B0=E6=8D=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/http.ts | 6 ++ src/api/jqka/mapPosition.ts | 97 +++++++++++++++++++++++ src/api/jqka/position.ts | 44 ++++++++++ src/api/jqka/types.ts | 43 ++++++++++ src/components/position/PositionPanel.vue | 71 ++++++++++++++--- src/components/position/PositionTable.vue | 2 +- src/composables/usePositions.ts | 8 +- src/config/contract.ts | 4 +- src/mocks/positions.ts | 17 +++- src/stores/quota.ts | 34 +++++++- src/types/index.ts | 17 ++++ src/utils/tradingDate.ts | 35 ++++++++ 12 files changed, 359 insertions(+), 19 deletions(-) create mode 100644 src/api/jqka/mapPosition.ts create mode 100644 src/api/jqka/position.ts create mode 100644 src/api/jqka/types.ts create mode 100644 src/utils/tradingDate.ts diff --git a/src/api/http.ts b/src/api/http.ts index 338dbc0..9ba55d0 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -5,3 +5,9 @@ export const baiduHttp = axios.create({ baseURL: '/baidu', timeout: 15000, }) + +/** 开发环境走 Vite 代理 /jqka → https://fupage.10jqka.com.cn */ +export const jqkaHttp = axios.create({ + baseURL: '/jqka', + timeout: 15000, +}) diff --git a/src/api/jqka/mapPosition.ts b/src/api/jqka/mapPosition.ts new file mode 100644 index 0000000..90935d0 --- /dev/null +++ b/src/api/jqka/mapPosition.ts @@ -0,0 +1,97 @@ +import type { PositionRow, PositionsData, TopTwentySum } from '../../types' +import type { JqkaDealPositionData, JqkaPositionItem } from './types' + +const TOP_N = 20 + +function withPercent(list: PositionRow[], totalHint?: number): PositionRow[] { + const total = + totalHint && totalHint > 0 + ? totalHint + : list.reduce((s, x) => s + Math.abs(x.qty), 0) + if (total <= 0) { + return list.map((x) => ({ ...x, percent: 0 })) + } + return list.map((x) => ({ + ...x, + percent: Number(((Math.abs(x.qty) / total) * 100).toFixed(2)), + })) +} + +function toRows( + list: JqkaPositionItem[], + pickQty: (x: JqkaPositionItem) => number, + pickChange: (x: JqkaPositionItem) => number, + opts?: { absQty?: boolean; filter?: (x: JqkaPositionItem) => boolean }, +): PositionRow[] { + const filtered = list.filter((item) => { + if (opts?.filter && !opts.filter(item)) return false + // 空持仓不展示、不参与统计 + return pickQty(item) !== 0 + }) + const ranked = [...filtered] + .sort((a, b) => Math.abs(pickQty(b)) - Math.abs(pickQty(a))) + .slice(0, TOP_N) + + return ranked.map((item, i) => { + const raw = pickQty(item) + return { + rank: i + 1, + name: item.company, + qty: opts?.absQty ? Math.abs(raw) : raw, + change: pickChange(item), + percent: 0, + } + }) +} + +/** + * 将同花顺持仓接口映射为页面用 PositionsData(总持仓 / 成交量 / 净持仓)。 + * 多空等列表均为非零持仓按数量排序后的前 20。 + */ +export function mapJqkaDealPosition(data: JqkaDealPositionData): PositionsData { + const list = data.positionList ?? [] + const sumLatest = data.topTwentyPositionSum?.[0] + + const long = withPercent( + toRows(list, (x) => x.f009n, (x) => x.f013n), + sumLatest?.f009nSum, + ) + const short = withPercent( + toRows(list, (x) => x.f015n, (x) => x.f019n), + sumLatest?.f015nSum, + ) + const volume = withPercent(toRows(list, (x) => x.f003n, (x) => x.f007n)) + + const netLong = withPercent( + toRows(list, (x) => x.f024n, (x) => x.f025n, { + filter: (x) => x.f024n > 0, + }), + ) + const netShort = withPercent( + toRows(list, (x) => x.f024n, (x) => x.f025n, { + absQty: true, + filter: (x) => x.f024n < 0, + }), + ) + + const topTwentySum: TopTwentySum[] = (data.topTwentyPositionSum ?? []).map( + (s) => ({ + tradeDate: s.tradeDate, + longSum: s.f009nSum, + shortSum: s.f015nSum, + }), + ) + + const updatedAt = + list[0]?.tradeDate || sumLatest?.tradeDate || new Date().toISOString().slice(0, 10) + + return { + long, + short, + volume, + netLong, + netShort, + topTwentySum, + updatedAt, + } +} \ No newline at end of file diff --git a/src/api/jqka/position.ts b/src/api/jqka/position.ts new file mode 100644 index 0000000..219d9ff --- /dev/null +++ b/src/api/jqka/position.ts @@ -0,0 +1,44 @@ +import { jqkaHttp } from '../http' +import type { JqkaDealPositionData, JqkaDealPositionResponse } from './types' +import { extractVariety, getPreviousTradingDate } from '../../utils/tradingDate' + +export interface GetDealPositionParams { + /** 合约代码,如 FG609 */ + contract: string + /** 品种代码;缺省时从 contract 字母前缀推导 */ + variety?: string + /** + * 查询日期 YYYY-MM-DD。 + * 缺省为上一交易日(接口一般只能查到上一交易日)。 + */ + date?: string +} + +/** + * 同花顺 — 期货会员成交持仓(龙虎榜) + * GET /futgwapi/api/market/v1/position/getDealPosition/?date=&variety=&contract= + */ +export async function getDealPosition( + params: GetDealPositionParams, +): Promise { + const contract = params.contract + const variety = params.variety ?? extractVariety(contract) + const date = params.date ?? getPreviousTradingDate() + + const { data } = await jqkaHttp.get( + '/futgwapi/api/market/v1/position/getDealPosition/', + { + params: { date, variety, contract }, + }, + ) + + if (data.code !== 0) { + throw new Error(`持仓接口失败: code=${data.code}, msg=${data.msg}`) + } + + if (!data.data?.positionList) { + throw new Error('持仓接口返回空 positionList') + } + + return data.data +} diff --git a/src/api/jqka/types.ts b/src/api/jqka/types.ts new file mode 100644 index 0000000..33c2f76 --- /dev/null +++ b/src/api/jqka/types.ts @@ -0,0 +1,43 @@ +/** 同花顺期货网关 — 会员持仓明细行 */ +export interface JqkaPositionItem { + tradeDate: string + contract: string + variety: string + company: string + /** 成交量 */ + f003n: number + /** 成交量增减 */ + f007n: number + /** 多单持仓数 */ + f009n: number + /** 多单增减 */ + f013n: number + /** 空单持仓数 */ + f015n: number + /** 空单增减 */ + f019n: number + /** 净持仓数 */ + f024n: number + /** 净持仓增减 */ + f025n: number +} + +/** 前 20 持仓量汇总(按交易日) */ +export interface JqkaTopTwentySum { + tradeDate: string + /** 多单持仓合计 */ + f009nSum: number + /** 空单持仓合计 */ + f015nSum: number +} + +export interface JqkaDealPositionData { + positionList: JqkaPositionItem[] + topTwentyPositionSum: JqkaTopTwentySum[] +} + +export interface JqkaDealPositionResponse { + code: number + msg: string + data: JqkaDealPositionData +} diff --git a/src/components/position/PositionPanel.vue b/src/components/position/PositionPanel.vue index a9f0414..576f27b 100644 --- a/src/components/position/PositionPanel.vue +++ b/src/components/position/PositionPanel.vue @@ -1,12 +1,16 @@ @@ -40,10 +67,23 @@ const tab = ref('total') margin-top: 16px; } -.placeholder-tip { +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.head :deep(.el-tabs) { + flex: 1; + min-width: 200px; +} + +.updated { font-size: 12px; color: var(--text-secondary); - margin: -4px 0 8px; + white-space: nowrap; } .cols { @@ -52,6 +92,11 @@ const tab = ref('total') gap: 20px; } +.cols.single { + grid-template-columns: 1fr; + max-width: 560px; +} + .long-title { margin: 0 0 8px; color: var(--long); @@ -64,9 +109,15 @@ const tab = ref('total') font-size: 14px; } +.volume-title { + margin: 0 0 8px; + color: var(--text-primary, #303133); + font-size: 14px; +} + @media (max-width: 960px) { .cols { grid-template-columns: 1fr; } } - + \ No newline at end of file diff --git a/src/components/position/PositionTable.vue b/src/components/position/PositionTable.vue index 7a20875..b1b89a6 100644 --- a/src/components/position/PositionTable.vue +++ b/src/components/position/PositionTable.vue @@ -37,4 +37,4 @@ withDefaults( .chg-down { color: var(--short); } - + \ No newline at end of file diff --git a/src/composables/usePositions.ts b/src/composables/usePositions.ts index ab6f84a..c60224a 100644 --- a/src/composables/usePositions.ts +++ b/src/composables/usePositions.ts @@ -1,8 +1,10 @@ import { storeToRefs } from 'pinia' +import { onMounted } from 'vue' import { useQuotaStore } from '../stores/quota' /** - * 机构持仓 composable:状态统一落在 quota store(暂 mock)。 + * 机构持仓 composable:状态统一落在 quota store。 + * 打开页面时按上一交易日拉取同花顺会员持仓。 */ export function usePositions() { const store = useQuotaStore() @@ -12,6 +14,10 @@ export function usePositions() { positionsError: error, } = storeToRefs(store) + onMounted(() => { + void store.fetchPositions() + }) + return { data, loading, diff --git a/src/config/contract.ts b/src/config/contract.ts index bf42162..7d1fa71 100644 --- a/src/config/contract.ts +++ b/src/config/contract.ts @@ -1,7 +1,9 @@ /** 当前关注的期货合约(后续可改为可切换) */ export const contractConfig = { - /** 合约代码,对应百度行情接口 code 参数 */ + /** 合约代码,对应百度行情 / 同花顺持仓接口 code/contract */ code: 'FG609', + /** 品种代码,持仓接口 variety;缺省可从 code 字母前缀推导 */ + variety: 'FG', /** 展示名称;接口未返回 name 时使用 */ name: '玻璃2609', /** 交易所展示名 */ diff --git a/src/mocks/positions.ts b/src/mocks/positions.ts index c967f78..5188ccd 100644 --- a/src/mocks/positions.ts +++ b/src/mocks/positions.ts @@ -68,8 +68,21 @@ const shortNames = [ '其他', ] +const long = withPercent(makeList(longNames, 650000)) +const short = withPercent(makeList(shortNames, 640000)) +const volume = withPercent(makeList(longNames, 480000)) +const netLong = withPercent(makeList(longNames.slice(0, 12), 120000)) +const netShort = withPercent(makeList(shortNames.slice(0, 12), 110000)) + export const positionsMock: PositionsData = { - long: withPercent(makeList(longNames, 650000)), - short: withPercent(makeList(shortNames, 640000)), + long, + short, + volume, + netLong, + netShort, + topTwentySum: [ + { tradeDate: '2026-07-15', longSum: 1073446, shortSum: 1502020 }, + { tradeDate: '2026-07-14', longSum: 1094199, shortSum: 1519906 }, + ], updatedAt: '2026-07-15', } diff --git a/src/stores/quota.ts b/src/stores/quota.ts index 5eab710..f660fa8 100644 --- a/src/stores/quota.ts +++ b/src/stores/quota.ts @@ -11,6 +11,12 @@ import { mapBaiduQuotationToQuote } from '../api/baidu/mapQuote' import { mapBaiduKlineToCandles } from '../api/baidu/mapKline' import { mapBaiduNewsToItems } from '../api/baidu/mapNews' import type { BaiduKlineType } from '../api/baidu/types' +import { getDealPosition } from '../api/jqka/position' +import { mapJqkaDealPosition } from '../api/jqka/mapPosition' +import { + extractVariety, + getPreviousTradingDate, +} from '../utils/tradingDate' /** K 线周期(日/周/月) */ export type KlinePeriod = 'day' | 'week' | 'month' @@ -55,7 +61,7 @@ export const useQuotaStore = defineStore('quota', () => { const newsLoading = ref(false) const newsError = ref(null) - // ─── 机构持仓(暂 mock,后续接 API)──────────────────── + // ─── 机构持仓 ─────────────────────────────────────────── const positions = ref(structuredClone(positionsMock)) const positionsLoading = ref(false) const positionsError = ref(null) @@ -121,13 +127,33 @@ export const useQuotaStore = defineStore('quota', () => { } } + /** + * 拉取会员持仓。日期默认上一交易日;若遇节假日空数据则再往前最多试 5 个交易日。 + */ async function fetchPositions() { positionsLoading.value = true positionsError.value = null try { - // TODO: 接入真实持仓 API 后替换 - await new Promise((r) => setTimeout(r, 300)) - positions.value = structuredClone(positionsMock) + const contract = contractConfig.code + const variety = contractConfig.variety || extractVariety(contract) + let date = getPreviousTradingDate() + let mapped: PositionsData | null = null + + for (let i = 0; i < 5; i++) { + const raw = await getDealPosition({ contract, variety, date }) + if (raw.positionList?.length) { + mapped = mapJqkaDealPosition(raw) + break + } + // 空列表:再往前一个交易日(覆盖长假) + const d = new Date(`${date}T12:00:00`) + date = getPreviousTradingDate(d) + } + + if (!mapped) { + throw new Error(`持仓无数据(已回溯至 ${date})`) + } + positions.value = mapped } catch (e) { positionsError.value = e console.error('[quota] 拉取持仓失败', e) diff --git a/src/types/index.ts b/src/types/index.ts index 8504123..6790e7e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -82,9 +82,26 @@ export interface PositionRow { percent: number } +/** 前 20 多空持仓汇总(按交易日) */ +export interface TopTwentySum { + tradeDate: string + longSum: number + shortSum: number +} + export interface PositionsData { + /** 多单持仓前 20(f009n / f013n) */ long: PositionRow[] + /** 空单持仓前 20(f015n / f019n) */ short: PositionRow[] + /** 成交量前 20(f003n / f007n) */ + volume: PositionRow[] + /** 净多持仓(f024n > 0) */ + netLong: PositionRow[] + /** 净空持仓(f024n < 0,qty 取绝对值) */ + netShort: PositionRow[] + /** 前 20 持仓量汇总 */ + topTwentySum: TopTwentySum[] updatedAt: string } diff --git a/src/utils/tradingDate.ts b/src/utils/tradingDate.ts new file mode 100644 index 0000000..27a874b --- /dev/null +++ b/src/utils/tradingDate.ts @@ -0,0 +1,35 @@ +/** + * 格式化为 YYYY-MM-DD(本地时区) + */ +export function formatDateYmd(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +function isWeekend(d: Date): boolean { + const day = d.getDay() + return day === 0 || day === 6 +} + +/** + * 上一个交易日(跳过周末;不含法定节假日日历)。 + * 持仓接口一般只能查到上一交易日数据,故默认用此日期。 + */ +export function getPreviousTradingDate(from: Date = new Date()): string { + const d = new Date(from.getFullYear(), from.getMonth(), from.getDate()) + d.setDate(d.getDate() - 1) + while (isWeekend(d)) { + d.setDate(d.getDate() - 1) + } + return formatDateYmd(d) +} + +/** + * 从合约代码提取品种,如 FG609 → FG、au2512 → au + */ +export function extractVariety(contract: string): string { + const m = contract.match(/^([A-Za-z]+)/) + return m ? m[1] : contract +}