From b73a910b43107fa98a5aee668197221e0a8bb28b Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 11 Feb 2026 21:25:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E9=99=90=E4=BB=B7?= =?UTF-8?q?=E5=8D=95=E6=8E=A5=E5=8F=A3=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/skills/xtrader-api-docs/SKILL.md | 9 +- src/api/constants.ts | 30 +++ src/api/event.ts | 30 ++- src/api/market.ts | 30 +++ src/components/MarketCard.vue | 12 +- src/components/TradeComponent.vue | 274 ++++++++++++++++++----- src/views/EventMarkets.vue | 1 + src/views/Home.vue | 7 +- src/views/TradeDetail.vue | 1 + 9 files changed, 331 insertions(+), 63 deletions(-) create mode 100644 src/api/constants.ts diff --git a/.cursor/skills/xtrader-api-docs/SKILL.md b/.cursor/skills/xtrader-api-docs/SKILL.md index 12f7798..5330879 100644 --- a/.cursor/skills/xtrader-api-docs/SKILL.md +++ b/.cursor/skills/xtrader-api-docs/SKILL.md @@ -105,8 +105,13 @@ Swagger UI 页面(如 [PmEvent findPmEvent](https://api.xtrader.vip/swagger/in ### 公开事件列表 `GET /PmEvent/getPmEventPublic` -- **Query**:page、pageSize、keyword、createdAtRange(array)。 -- **响应 200**:`data` 为 `response.PageResult`(list、page、pageSize、total);list 项为 `polymarket.PmEvent`,内含 markets、series、tags 等,markets[].outcomePrices 为价格数组,首项对应 Yes 概率。 +- **Query**:page、pageSize、keyword、createdAtRange(array)、tokenid(可选,来自 market.clobTokenIds 的值,可传单个或数组)。 +- **响应 200**:`data` 为 `response.PageResult`(list、page、pageSize、total);list 项为 `polymarket.PmEvent`,内含 markets、series、tags 等,markets[].outcomePrices 为价格数组,首项对应 Yes 概率;markets[].clobTokenIds 与 outcomes/outcomePrices 顺序一致。 + +### 订单类型与交易方向(用于交易接口,`src/api/constants.ts`) + +- **OrderType**:GTC=0(一直有效直到取消)、GTD=1(指定时间内有效)、FOK=2(全部成交或取消)、FAK=3(立即成交剩余取消)、Market=4(市价单)。 +- **Side**:Buy=1、Sell=2。 ### 通用响应与鉴权 diff --git a/src/api/constants.ts b/src/api/constants.ts new file mode 100644 index 0000000..6330e6c --- /dev/null +++ b/src/api/constants.ts @@ -0,0 +1,30 @@ +/** + * 订单类型(OrderType) + * 用于交易接口入参 + */ +export const OrderType = { + /** Good Till Cancelled: 一直有效直到取消 */ + GTC: 0, + /** Good Till Date: 指定时间内有效 */ + GTD: 1, + /** Fill Or Kill: 全部成交或立即取消(不允许部分成交) */ + FOK: 2, + /** Fill And Kill (IOC): 立即成交,剩余部分取消 */ + FAK: 3, + /** Market Order: 市价单,立即按最优价成交,剩余取消 (同 FAK,但不限价) */ + Market: 4, +} as const + +export type OrderTypeValue = (typeof OrderType)[keyof typeof OrderType] + +/** + * 交易方向(Side) + */ +export const Side = { + /** 买入 */ + Buy: 1, + /** 卖出 */ + Sell: 2, +} as const + +export type SideValue = (typeof Side)[keyof typeof Side] diff --git a/src/api/event.ts b/src/api/event.ts index 3c39863..b484da4 100644 --- a/src/api/event.ts +++ b/src/api/event.ts @@ -65,8 +65,8 @@ export interface PmEventMarketItem { outcomes?: string[] /** 各选项价格,outcomes[0] 对应 outcomePrices[0] */ outcomePrices?: string[] | number[] - /** 市场对应的 clob token id 与 outcomePrices 和outcomes顺序一致 */ - clobTokenIds:string[] + /** 市场对应的 clob token id 与 outcomePrices、outcomes 顺序一致,outcomes[0] 对应 clobTokenIds[0] */ + clobTokenIds?: string[] endDate?: string volume?: number [key: string]: unknown @@ -79,6 +79,16 @@ export function getMarketId(m: PmEventMarketItem | null | undefined): string | u return raw != null ? String(raw) : undefined } +/** 从市场项取 clobTokenId,outcomeIndex 0=Yes/第一选项,1=No/第二选项 */ +export function getClobTokenId( + m: PmEventMarketItem | null | undefined, + outcomeIndex: 0 | 1 = 0 +): string | undefined { + if (!m?.clobTokenIds?.length) return undefined + const id = m.clobTokenIds[outcomeIndex] + return id != null ? String(id) : undefined +} + /** 对应 definitions polymarket.PmSeries 常用字段 */ export interface PmEventSeriesItem { ID?: number @@ -109,22 +119,30 @@ export interface GetPmEventListParams { keyword?: string /** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */ createdAtRange?: string[] + /** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */ + tokenid?: string | string[] } /** * 分页获取 Event 列表(公开接口,不需要鉴权) * GET /PmEvent/getPmEventPublic + * + * Query: page, pageSize, keyword, createdAtRange, tokenid + * tokenid 对应 market.clobTokenIds 中的值,可传单个或数组 */ export async function getPmEventPublic( params: GetPmEventListParams = {} ): Promise { - const { page = 1, pageSize = 10, keyword, createdAtRange } = params + const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid } = params const query: Record = { page, pageSize, } if (keyword != null && keyword !== '') query.keyword = keyword if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange + if (tokenid != null) { + query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid] + } return get('/PmEvent/getPmEventPublic', query) } @@ -186,6 +204,8 @@ export interface EventCardOutcome { noLabel?: string /** 可选,用于交易时区分 market */ marketId?: string + /** 用于下单 tokenId,与 outcomes 顺序一致 */ + clobTokenIds?: string[] } /** @@ -213,6 +233,8 @@ export interface EventCardItem { isNew?: boolean /** 当前市场 ID(单 market 时为第一个 market 的 ID,供交易/Split 使用) */ marketId?: string + /** 用于下单 tokenId,单 market 时取自 firstMarket.clobTokenIds */ + clobTokenIds?: string[] } /** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */ @@ -298,6 +320,7 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem { yesLabel: m.outcomes?.[0] ?? 'Yes', noLabel: m.outcomes?.[1] ?? 'No', marketId: getMarketId(m), + clobTokenIds: m.clobTokenIds, })) : undefined @@ -316,5 +339,6 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem { noLabel: firstMarket?.outcomes?.[1] ?? 'No', isNew: item.new === true, marketId: getMarketId(firstMarket), + clobTokenIds: firstMarket?.clobTokenIds, } } diff --git a/src/api/market.ts b/src/api/market.ts index 87550a2..e38c58b 100644 --- a/src/api/market.ts +++ b/src/api/market.ts @@ -7,6 +7,36 @@ export interface ApiResponse { msg: string } +/** + * 下单请求体(/clob/gateway/submitOrder) + * tokenID 来自 market.clobTokenIds,outcomeIndex 0=Yes 1=No + */ +export interface ClobSubmitOrderRequest { + expiration: number + feeRateBps: number + nonce: number + orderType: number + /** 价格(整数,已乘 10000) */ + price: number + side: number + /** 数量(份额) */ + size: number + taker: boolean + tokenID: string + userID: number +} + +/** + * POST /clob/gateway/submitOrder + * 下单(买入/卖出 Yes 或 No),需鉴权 + */ +export async function pmOrderPlace( + data: ClobSubmitOrderRequest, + config?: { headers?: Record } +): Promise { + return post('/clob/gateway/submitOrder', data, config) +} + /** * Split 请求体(/PmMarket/split) * 用 USDC 兑换该市场的 Yes+No 份额(1 USDC ≈ 1 Yes + 1 No) diff --git a/src/components/MarketCard.vue b/src/components/MarketCard.vue index 97785e0..04cc6e2 100644 --- a/src/components/MarketCard.vue +++ b/src/components/MarketCard.vue @@ -171,7 +171,7 @@ const router = useRouter() const emit = defineEmits<{ openTrade: [ side: 'yes' | 'no', - market?: { id: string; title: string; marketId?: string; outcomeTitle?: string } + market?: { id: string; title: string; marketId?: string; outcomeTitle?: string; clobTokenIds?: string[] } ] }>() @@ -198,6 +198,8 @@ const props = withDefaults( isNew?: boolean /** 当前市场 ID(单 market 时供交易/Split 使用) */ marketId?: string + /** 用于下单 tokenId,单 market 时 */ + clobTokenIds?: string[] }>(), { marketTitle: 'Mamdan opens city-owned grocery store b...', @@ -289,7 +291,12 @@ const navigateToDetail = () => { } function openTradeSingle(side: 'yes' | 'no') { - emit('openTrade', side, { id: props.id, title: props.marketTitle, marketId: props.marketId }) + emit('openTrade', side, { + id: props.id, + title: props.marketTitle, + marketId: props.marketId, + clobTokenIds: props.clobTokenIds, + }) } function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) { @@ -298,6 +305,7 @@ function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) { title: outcome.title, marketId: outcome.marketId, outcomeTitle: outcome.title, + clobTokenIds: outcome.clobTokenIds, }) } diff --git a/src/components/TradeComponent.vue b/src/components/TradeComponent.vue index 651b479..5688f7f 100644 --- a/src/components/TradeComponent.vue +++ b/src/components/TradeComponent.vue @@ -84,8 +84,9 @@ +

{{ orderError }}

- + {{ actionButtonText }} @@ -172,13 +173,17 @@ mdi-minus mdi-plus @@ -193,12 +198,15 @@ Shares
@@ -267,8 +275,8 @@ - - +

{{ orderError }}

+ {{ actionButtonText }} @@ -314,7 +322,8 @@
You'll receivemdi-currency-usd ${{ totalPrice }}
- {{ actionButtonText }} +

{{ orderError }}

+ {{ actionButtonText }} - {{ actionButtonText }} +

{{ orderError }}

+ {{ actionButtonText }} @@ -462,41 +472,42 @@ - - {{ actionButtonText }} - - + +

{{ orderError }}

+ {{ actionButtonText }} + + - {{ actionButtonText }} +

{{ orderError }}

+ {{ actionButtonText }} @@ -648,7 +660,8 @@ import { ref, computed, watch, onMounted } from 'vue' import { useDisplay } from 'vuetify' import { useUserStore } from '../stores/user' -import { pmMarketMerge, pmMarketSplit } from '../api/market' +import { pmMarketMerge, pmMarketSplit, pmOrderPlace } from '../api/market' +import { OrderType, Side } from '../api/constants' const { mobile } = useDisplay() const userStore = useUserStore() @@ -658,6 +671,8 @@ export interface TradeMarketPayload { yesPrice: number noPrice: number title?: string + /** 与 outcomes/outcomePrices 顺序一致,用于下单 tokenId:0=Yes 1=No */ + clobTokenIds?: string[] } const props = withDefaults( @@ -762,7 +777,7 @@ const limitType = ref('Limit') const expirationEnabled = ref(false) const selectedOption = ref<'yes' | 'no'>(props.initialOption ?? 'no') const limitPrice = ref(0.82) // 初始限价,单位:美元 -const shares = ref(20) // 初始份额 +const shares = ref(20) // 初始份额(正整数) const expirationTime = ref('5m') // 初始过期时间 const expirationOptions = ref(['5m', '15m', '30m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d']) // 过期时间选项 @@ -771,6 +786,9 @@ const isMarketMode = computed(() => limitType.value === 'Market') const amount = ref(0) // Market mode amount const balance = ref(0) // Market mode balance +const orderLoading = ref(false) +const orderError = ref('') + // Emits const emit = defineEmits<{ optionChange: [option: 'yes' | 'no'] @@ -803,11 +821,15 @@ function applyInitialOption(option: 'yes' | 'no') { syncLimitPriceFromMarket() } +function clampLimitPrice(v: number): number { + return Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)) +} + /** 根据当前 props.market 与 selectedOption 同步 limitPrice(组件显示或 market 更新时调用) */ function syncLimitPriceFromMarket() { const yesP = props.market?.yesPrice ?? 0.19 const noP = props.market?.noPrice ?? 0.82 - limitPrice.value = selectedOption.value === 'yes' ? yesP : noP + limitPrice.value = clampLimitPrice(selectedOption.value === 'yes' ? yesP : noP) } onMounted(() => { @@ -820,6 +842,7 @@ watch(() => props.initialOption, (option) => { watch(() => props.market, (m) => { if (m) { + orderError.value = '' if (props.initialOption) applyInitialOption(props.initialOption) else syncLimitPriceFromMarket() } @@ -830,29 +853,90 @@ const handleOptionChange = (option: 'yes' | 'no') => { selectedOption.value = option const yesP = props.market?.yesPrice ?? 0.19 const noP = props.market?.noPrice ?? 0.82 - limitPrice.value = option === 'yes' ? yesP : noP + limitPrice.value = clampLimitPrice(option === 'yes' ? yesP : noP) emit('optionChange', option) } -// 限价调整方法 +/** 仅在值在 [0,1] 且为有效数字时更新,否则保持原值不变 */ +function onLimitPriceInput(v: unknown) { + const num = v == null ? NaN : Number(v) + if (!Number.isFinite(num) || num < 0 || num > 1) return + limitPrice.value = num +} + +/** 只允许数字和小数点输入 */ +function onLimitPriceKeydown(e: KeyboardEvent) { + const key = e.key + const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End'] + if (allowed.includes(key)) return + if (e.ctrlKey || e.metaKey) { + if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return + } + if (key >= '0' && key <= '9') return + if (key === '.' && !String((e.target as HTMLInputElement)?.value ?? '').includes('.')) return + e.preventDefault() +} + +/** 粘贴时只接受有效数字 */ +function onLimitPricePaste(e: ClipboardEvent) { + const text = e.clipboardData?.getData('text') ?? '' + const num = parseFloat(text) + if (!Number.isFinite(num) || num < 0 || num > 1) { + e.preventDefault() + } +} + +// 限价调整方法(0–1 区间) const decreasePrice = () => { - limitPrice.value = Math.max(0.01, limitPrice.value - 0.01) + limitPrice.value = clampLimitPrice(limitPrice.value - 0.01) } const increasePrice = () => { - limitPrice.value += 0.01 + limitPrice.value = clampLimitPrice(limitPrice.value + 0.01) } -// 份额调整方法 +/** 将 shares 限制为正整数(>= 1) */ +function clampShares(v: number): number { + const n = Math.floor(Number.isFinite(v) ? v : 1) + return Math.max(1, n) +} + +/** 仅在值为正整数时更新 shares */ +function onSharesInput(v: unknown) { + const num = v == null ? NaN : Number(v) + const n = Math.floor(num) + if (!Number.isFinite(num) || n < 1 || num !== n) return + shares.value = n +} + +/** 只允许数字输入(Shares 为正整数) */ +function onSharesKeydown(e: KeyboardEvent) { + const key = e.key + const allowed = ['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End'] + if (allowed.includes(key)) return + if (e.ctrlKey || e.metaKey) { + if (['a', 'c', 'v', 'x'].includes(key.toLowerCase())) return + } + if (key >= '0' && key <= '9') return + e.preventDefault() +} + +/** 粘贴时只接受正整数 */ +function onSharesPaste(e: ClipboardEvent) { + const text = e.clipboardData?.getData('text') ?? '' + const num = parseInt(text, 10) + if (!Number.isFinite(num) || num < 1) e.preventDefault() +} + +// 份额调整方法(保证正整数) const adjustShares = (amount: number) => { - shares.value = Math.max(0, shares.value + amount) + shares.value = clampShares(shares.value + amount) } // 份额百分比调整方法(仅在Sell模式下使用) const setSharesPercentage = (percentage: number) => { - // 假设最大份额为100,实际应用中可能需要根据用户的可用份额来计算 const maxShares = 100 - shares.value = Math.round((maxShares * percentage) / 100) + shares.value = clampShares(Math.round((maxShares * percentage) / 100)) } // Market mode methods @@ -870,17 +954,95 @@ const deposit = () => { // 实际应用中,这里应该调用存款API } -// 提交订单(含 Set expiration) -function submitOrder() { - emit('submit', { +/** 将 expirationTime 如 "5m" 转为 Unix 秒级时间戳(GTD 用),0 表示无过期 */ +function parseExpirationTimestamp(expTime: string): number { + const m = /^(\d+)(m|h|d)$/i.exec(expTime) + if (!m) return 0 + const [, num, unit] = m + const n = parseInt(num ?? '0', 10) + if (!Number.isFinite(n) || n <= 0) return 0 + const now = Date.now() + let ms = n + if (unit?.toLowerCase() === 'm') ms = n * 60 * 1000 + else if (unit?.toLowerCase() === 'h') ms = n * 3600 * 1000 + else if (unit?.toLowerCase() === 'd') ms = n * 86400 * 1000 + return Math.floor((now + ms) / 1000) +} + +// 提交订单(含 Set expiration):对接 /clob/gateway/submitOrder +async function submitOrder() { + const marketId = props.market?.marketId + const clobIds = props.market?.clobTokenIds + const outcomeIndex = selectedOption.value === 'yes' ? 0 : 1 + const tokenId = clobIds?.[outcomeIndex] + + const payload = { side: activeTab.value as 'buy' | 'sell', option: selectedOption.value, limitPrice: limitPrice.value, shares: shares.value, expirationEnabled: expirationEnabled.value, expirationTime: expirationTime.value, - ...(props.market?.marketId != null && { marketId: props.market.marketId }), - }) + ...(marketId != null && { marketId }), + } + emit('submit', payload) + + if (!tokenId) { + orderError.value = '请先选择市场(需包含 clobTokenIds)' + return + } + const headers = userStore.getAuthHeaders() + if (!headers) { + orderError.value = '请先登录' + return + } + const uid = userStore.user?.id ?? userStore.user?.ID + const userIdNum = uid != null ? Number(uid) : 0 + if (!Number.isFinite(userIdNum) || userIdNum <= 0) { + orderError.value = '用户信息异常' + return + } + + const isMarket = limitType.value === 'Market' + const orderTypeNum = isMarket + ? OrderType.Market + : expirationEnabled.value + ? OrderType.GTD + : OrderType.GTC + const sideNum = activeTab.value === 'buy' ? Side.Buy : Side.Sell + const expiration = + orderTypeNum === OrderType.GTD && expirationEnabled.value + ? parseExpirationTimestamp(expirationTime.value) + : 0 + + orderLoading.value = true + orderError.value = '' + try { + const res = await pmOrderPlace( + { + expiration, + feeRateBps: 0, + nonce: 0, + orderType: orderTypeNum, + price: limitPrice.value, + side: sideNum, + size: clampShares(shares.value), + taker: true, + tokenID: tokenId, + userID: userIdNum, + }, + { headers } + ) + if (res.code === 0 || res.code === 200) { + userStore.fetchUsdcBalance() + } else { + orderError.value = res.msg || '下单失败' + } + } catch (e) { + orderError.value = e instanceof Error ? e.message : 'Request failed' + } finally { + orderLoading.value = false + } } @@ -1423,6 +1585,12 @@ function submitOrder() { color: #dc2626; } +.order-error { + font-size: 13px; + color: #dc2626; + margin: 8px 0 0; +} + .merge-dialog-actions { padding: 16px 20px 20px; padding-top: 8px; diff --git a/src/views/EventMarkets.vue b/src/views/EventMarkets.vue index 1e86fe1..288f5fb 100644 --- a/src/views/EventMarkets.vue +++ b/src/views/EventMarkets.vue @@ -156,6 +156,7 @@ const tradeMarketPayload = computed(() => { yesPrice, noPrice, title: m.question, + clobTokenIds: m.clobTokenIds, } }) diff --git a/src/views/Home.vue b/src/views/Home.vue index c0c6215..10cfe7b 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -30,6 +30,7 @@ :no-label="card.noLabel" :is-new="card.isNew" :market-id="card.marketId" + :clob-token-ids="card.clobTokenIds" @open-trade="onCardOpenTrade" />
@@ -202,7 +203,7 @@ const noMoreEvents = computed(() => { const footerLang = ref('English') const tradeDialogOpen = ref(false) const tradeDialogSide = ref<'yes' | 'no'>('yes') -const tradeDialogMarket = ref<{ id: string; title: string; marketId?: string } | null>(null) +const tradeDialogMarket = ref<{ id: string; title: string; marketId?: string; clobTokenIds?: string[] } | null>(null) const scrollRef = ref(null) function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: string; marketId?: string }) { @@ -211,7 +212,7 @@ function onCardOpenTrade(side: 'yes' | 'no', market?: { id: string; title: strin tradeDialogOpen.value = true } -/** 传给 TradeComponent 的 market(Home 弹窗/底部栏),供 Split 等使用;优先用 marketId,无则用事件 id */ +/** 传给 TradeComponent 的 market(Home 弹窗/底部栏),供 Split、下单等使用 */ const homeTradeMarketPayload = computed(() => { const m = tradeDialogMarket.value if (!m) return undefined @@ -219,7 +220,7 @@ const homeTradeMarketPayload = computed(() => { const chance = 50 const yesPrice = Math.min(1, Math.max(0, chance / 100)) const noPrice = 1 - yesPrice - return { marketId, yesPrice, noPrice, title: m.title } + return { marketId, yesPrice, noPrice, title: m.title, clobTokenIds: m.clobTokenIds } }) const sentinelRef = ref(null) diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue index d5081dd..0efeaed 100644 --- a/src/views/TradeDetail.vue +++ b/src/views/TradeDetail.vue @@ -265,6 +265,7 @@ const tradeMarketPayload = computed(() => { yesPrice, noPrice, title: m.question, + clobTokenIds: m.clobTokenIds, } } const qId = route.query.marketId