持仓数据接口

This commit is contained in:
dongzp 2026-07-21 16:14:28 +08:00
parent 6a81305bad
commit 249be8d882
12 changed files with 359 additions and 19 deletions

View File

@ -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,
})

View File

@ -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,
}
}

44
src/api/jqka/position.ts Normal file
View File

@ -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<JqkaDealPositionData> {
const contract = params.contract
const variety = params.variety ?? extractVariety(contract)
const date = params.date ?? getPreviousTradingDate()
const { data } = await jqkaHttp.get<JqkaDealPositionResponse>(
'/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
}

43
src/api/jqka/types.ts Normal file
View File

@ -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
}

View File

@ -1,12 +1,16 @@
<template>
<section class="position-panel card">
<el-tabs v-model="tab">
<el-tab-pane label="总持仓" name="total" />
<el-tab-pane label="成交量" name="volume" />
<el-tab-pane label="净持仓" name="net" />
</el-tabs>
<div v-if="tab !== 'total'" class="placeholder-tip">当前为占位数据(结构与总持仓一致)</div>
<div class="cols">
<div class="head">
<el-tabs v-model="tab">
<el-tab-pane label="总持仓" name="total" />
<el-tab-pane label="成交量" name="volume" />
<el-tab-pane label="净持仓" name="net" />
</el-tabs>
<span v-if="data.updatedAt" class="updated">数据日期 {{ data.updatedAt }}</span>
</div>
<!-- 总持仓:多 / 空 -->
<div v-if="tab === 'total'" class="cols">
<div class="col">
<h3 class="long-title">多单持仓前20名</h3>
<PositionDonut :list="data.long" />
@ -18,6 +22,29 @@
<PositionTable :list="data.short" qty-label="空单" />
</div>
</div>
<!-- 成交量:单列 -->
<div v-else-if="tab === 'volume'" class="cols single">
<div class="col">
<h3 class="volume-title">成交量前20名</h3>
<PositionDonut :list="data.volume" />
<PositionTable :list="data.volume" qty-label="成交量" />
</div>
</div>
<!-- 净持仓:净多 / 净空 -->
<div v-else class="cols">
<div class="col">
<h3 class="long-title">净多持仓前20名</h3>
<PositionDonut :list="data.netLong" />
<PositionTable :list="data.netLong" qty-label="净持仓" />
</div>
<div class="col">
<h3 class="short-title">净空持仓前20名</h3>
<PositionDonut :list="data.netShort" />
<PositionTable :list="data.netShort" qty-label="净持仓" />
</div>
</div>
</section>
</template>
@ -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,6 +109,12 @@ 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;

View File

@ -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,

View File

@ -1,7 +1,9 @@
/** 当前关注的期货合约(后续可改为可切换) */
export const contractConfig = {
/** 合约代码,对应百度行情接口 code 参数 */
/** 合约代码,对应百度行情 / 同花顺持仓接口 code/contract */
code: 'FG609',
/** 品种代码,持仓接口 variety;缺省可从 code 字母前缀推导 */
variety: 'FG',
/** 展示名称;接口未返回 name 时使用 */
name: '玻璃2609',
/** 交易所展示名 */

View File

@ -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',
}

View File

@ -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<unknown>(null)
// ─── 机构持仓(暂 mock,后续接 API)────────────────────
// ─── 机构持仓 ───────────────────────────────────────────
const positions = ref<PositionsData>(structuredClone(positionsMock))
const positionsLoading = ref(false)
const positionsError = ref<unknown>(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)

View File

@ -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
}

35
src/utils/tradingDate.ts Normal file
View File

@ -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
}