فهرست منبع

更新
增加钱包余额展示
增加钱包余额转移
策略页面显示详情

flyzto 3 هفته پیش
والد
کامیت
e6197bdf65
47فایلهای تغییر یافته به همراه4740 افزوده شده و 3331 حذف شده
  1. 0 15
      pinnacle/betsapi_zh.html
  2. 0 46
      pinnacle/libs/cache.js
  3. 0 79
      pinnacle/libs/getDateInTimezone.js
  4. 0 45
      pinnacle/libs/logs.js
  5. 0 234
      pinnacle/libs/parseGameData.js
  6. 0 321
      pinnacle/libs/pinnacleClient.js
  7. 0 401
      pinnacle/linesapi_zh.html
  8. 0 771
      pinnacle/main.js
  9. 0 383
      pinnacle/package-lock.json
  10. 0 20
      pinnacle/package.json
  11. 58 0
      polymarket/apikey.js
  12. 163 0
      polymarket/balance.js
  13. 59 0
      polymarket/deposit.js
  14. 12 0
      polymarket/libs/auth.js
  15. 185 47
      polymarket/libs/parseMarkets.js
  16. 186 0
      polymarket/libs/pinnacleClient.js
  17. 563 52
      polymarket/libs/polymarketClient.js
  18. 386 0
      polymarket/libs/syncData.js
  19. 56 342
      polymarket/main.js
  20. 19 0
      polymarket/middleware/requireInternalToken.js
  21. 855 45
      polymarket/package-lock.json
  22. 11 2
      polymarket/package.json
  23. 127 0
      polymarket/routes/trading.js
  24. 303 0
      polymarket/transfer.js
  25. 6 5
      server/libs/auth.js
  26. 65 0
      server/libs/platformRequest.js
  27. 9 8
      server/main.js
  28. 19 0
      server/middleware/requireInternalToken.js
  29. 213 0
      server/models/Games.bak.js
  30. 64 41
      server/models/Games.js
  31. 458 0
      server/models/Markets.bak.js
  32. 133 272
      server/models/Markets.js
  33. 9 7
      server/models/Partner.js
  34. 0 97
      server/models/PartnerGate.js
  35. 9 18
      server/models/Platforms.js
  36. 0 3
      server/models/Translation.js
  37. 23 1
      server/routes/games.js
  38. 0 17
      server/routes/partnerGate.js
  39. 2 2
      server/state/locales.js
  40. 2 2
      server/state/store.js
  41. 8 5
      server/triangle/trangleCalc.js
  42. 2 8
      web/src/main.js
  43. 5 11
      web/src/main.vue
  44. 6 0
      web/src/router/index.js
  45. 333 30
      web/src/views/home.vue
  46. 390 0
      web/src/views/wallet.vue
  47. 1 1
      web/vite.config.js

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 15
pinnacle/betsapi_zh.html


+ 0 - 46
pinnacle/libs/cache.js

@@ -1,46 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-export const getData = (file) => {
-  let data = null;
-
-  if (fs.existsSync(file)) {
-    const arrayBuffer = fs.readFileSync(file);
-
-    try {
-      data = JSON.parse(arrayBuffer.toString());
-    }
-    catch (e) {}
-  }
-
-  return Promise.resolve(data);
-}
-
-export const setData = (file, data, indent = 2) => {
-  return new Promise((resolve, reject) => {
-
-    if (typeof (data) != 'string') {
-      try {
-        data = JSON.stringify(data, null, indent);
-      }
-      catch (error) {
-        reject(error);
-      }
-    }
-
-    const directoryPath = path.dirname(file);
-    if(!fs.existsSync(directoryPath)) {
-      fs.mkdirSync(directoryPath, { recursive: true });
-    }
-
-    try {
-      fs.writeFileSync(file, data);
-      resolve();
-    }
-    catch (error) {
-      reject(error);
-    }
-  });
-}
-
-export default { getData, setData };

+ 0 - 79
pinnacle/libs/getDateInTimezone.js

@@ -1,79 +0,0 @@
-/**
- * 将时区偏移小时转换为 ±HHMM 格式
- * @param {number|string} offset 如 8, -4, "+8", "-04"
- * @returns {string} 如 "+0800", "-0400"
- */
-const formatTimezoneOffset = (offset) => {
-  const hours = Number(offset);
-  if (Number.isNaN(hours)) {
-    throw new Error('Invalid timezone offset');
-  }
-
-  const sign = hours >= 0 ? '+' : '-';
-  const hh = String(Math.abs(hours)).padStart(2, '0');
-
-  return `${sign}${hh}00`;
-}
-
-
-/**
- * 判断日期是否无效
- * @param {Date} date
- * @returns {boolean}
- */
-const isInvalidDate = (date) => {
-  return date instanceof Date && Number.isNaN(date.getTime());
-}
-
-
-/**
- * 获取指定时区当前日期或时间
- * @param {number} offset - 时区相对 UTC 的偏移(例如:-4 表示 GMT-4)
- * @param {number} timestamp - 时间戳(可选)
- * @param {boolean} [withTime=false] - 是否返回完整时间(默认只返回日期)
- * @returns {string} 格式化的日期或时间字符串
- */
-const getDateInTimezone = (offset, timestamp, withTime=false) => {
-
-  offset = Number(offset);
-  if (Number.isNaN(offset)) {
-    throw new Error('Invalid timezone offset');
-  }
-
-  if (typeof(timestamp) === 'undefined') {
-    timestamp = Date.now();
-  }
-  else if (typeof(timestamp) === 'boolean') {
-    withTime = timestamp;
-    timestamp = Date.now();
-  }
-  else if (typeof(timestamp) === 'string') {
-    const date = new Date(timestamp);
-    if (isInvalidDate(date)) {
-      throw new Error('Invalid timestamp');
-    }
-    timestamp = date.getTime();
-  }
-  else if (typeof(timestamp) !== 'number') {
-    throw new Error('Invalid timestamp');
-  }
-
-  const nowUTC = new Date(timestamp);
-  const targetTime = new Date(nowUTC.getTime() + offset * 60 * 60 * 1000);
-
-  const year = targetTime.getUTCFullYear();
-  const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
-  const day = String(targetTime.getUTCDate()).padStart(2, '0');
-
-  if (!withTime) {
-    return `${year}-${month}-${day}`;
-  }
-
-  const hours = String(targetTime.getUTCHours()).padStart(2, '0');
-  const minutes = String(targetTime.getUTCMinutes()).padStart(2, '0');
-  const seconds = String(targetTime.getUTCSeconds()).padStart(2, '0');
-
-  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${formatTimezoneOffset(offset)}`;
-}
-
-export default getDateInTimezone;

+ 0 - 45
pinnacle/libs/logs.js

@@ -1,45 +0,0 @@
-import dayjs from 'dayjs';
-
-export default class Logs {
-
-  static out(...args) {
-    const timeString = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
-    if (typeof args[0] === 'string' && args[0].includes('%')) {
-      args[0] = `[${timeString}] ` + args[0];
-    }
-    else {
-      args.unshift(`[${timeString}]`);
-    }
-    console.log(...args);
-  }
-
-  static err(...args) {
-    const timeString = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
-    if (typeof args[0] === 'string' && args[0].includes('%')) {
-      args[0] = `[${timeString}] ` + args[0];
-    }
-    else {
-      args.unshift(`[${timeString}]`);
-    }
-    console.error(...args);
-  }
-
-  static outDev(...args) {
-    if (process.env.NODE_ENV == 'development') {
-      this.out(...args);
-    }
-  }
-
-  static errDev(...args) {
-    if (process.env.NODE_ENV == 'development') {
-      this.err(...args);
-    }
-  }
-
-  static outLine(string) {
-    process.stdout.write("\u001b[1A");
-    process.stdout.write("\u001b[2K");
-    this.out(string);
-  }
-
-}

+ 0 - 234
pinnacle/libs/parseGameData.js

@@ -1,234 +0,0 @@
-/**
- * 解析让球方
- */
-const ratioAccept = (ratio) => {
-  if (ratio > 0) {
-    return 'a';
-  }
-  return '';
-}
-
-/**
- * 将比率转换为字符串
- * @param {*} ratio
- * @returns
- */
-const ratioString = (ratio) => {
-  ratio = Math.abs(ratio);
-  ratio = ratio.toString();
-  ratio = ratio.replace(/\./, '');
-  return ratio;
-}
-
-/**
- * 解析胜平负盘口
- * @param {*} moneyline
- * @returns
- */
-const parseMoneyline = (moneyline, maxMoneyline) => {
-  // 胜平负
-  if (!moneyline) {
-    return null;
-  }
-  const { home, away, draw } = moneyline;
-  const max = maxMoneyline;
-  return {
-    'ior_mh': { v: home, m: max },
-    'ior_mc': { v: away, m: max },
-    'ior_mn': { v: draw, m: max },
-  }
-}
-
-/**
- * 解析让分盘口
- * @param {*} spreads
- * @param {*} wm
- * @returns
- */
-const parseSpreads = (spreads, wm, maxSpread) => {
-  // 让分盘
-  if (!spreads?.length) {
-    return null;
-  }
-  const events = {};
-  spreads.forEach(spread => {
-    const { hdp, home, away, max=maxSpread } = spread;
-
-    // if (!(hdp % 1) || !!(hdp % 0.5)) {
-    //   // 整数或不能被0.5整除的让分盘不处理
-    //   return;
-    // }
-
-    const ratio_ro = hdp;
-    const ratio_r = ratio_ro - wm;
-    events[`ior_r${ratioAccept(ratio_r)}h_${ratioString(ratio_r)}`] = {
-      v: home,
-      r: wm != 0 ? `ior_r${ratioAccept(ratio_ro)}h_${ratioString(ratio_ro)}` : undefined,
-      m: max,
-    };
-    events[`ior_r${ratioAccept(-ratio_r)}c_${ratioString(ratio_r)}`] = {
-      v: away,
-      r: wm != 0 ? `ior_r${ratioAccept(-ratio_ro)}c_${ratioString(ratio_ro)}` : undefined,
-      m: max,
-    };
-  });
-  return events;
-}
-
-/**
- * 解析大小球盘口
- * @param {*} totals
- * @returns
- */
-const parseTotals = (totals, maxTotal) => {
-  // 大小球盘
-  if (!totals?.length) {
-    return null;
-  }
-  const events = {};
-
-  totals.forEach(total => {
-    const { points, over, under, max=maxTotal } = total;
-    events[`ior_ouc_${ratioString(points)}`] = { v: over, m: max };
-    events[`ior_ouh_${ratioString(points)}`] = { v: under, m: max };
-  });
-  return events;
-}
-
-/**
- * 解析直赛盘口
- * @param {*} straight
- * @param {*} wm
- * @returns
- */
-const parseStraight = (straight, wm) => {
-  if (!straight) {
-    return null;
-  }
-  const { cutoff='', status=0, spreads=[], moneyline={}, totals=[], maxSpread, maxMoneyline, maxTotal } = straight;
-  const cutoffTime = new Date(cutoff).getTime();
-  const nowTime = Date.now();
-
-  if (status != 1 || cutoffTime < nowTime) {
-    return null;
-  }
-
-  const events = {};
-  Object.assign(events, parseSpreads(spreads, wm, maxSpread));
-  Object.assign(events, parseMoneyline(moneyline, maxMoneyline));
-  Object.assign(events, parseTotals(totals, maxTotal));
-
-  return events;
-}
-
-/**
- * 解析净胜球盘口
- * @param {*} winningMargin
- * @param {*} home
- * @param {*} away
- * @returns
- */
-const parseWinningMargin = (winningMargin, home, away) => {
-  if (!winningMargin) {
-    return null;
-  }
-  const { cutoff='', status='', contestants=[] } = winningMargin;
-  const cutoffTime = new Date(cutoff).getTime();
-  const nowTime = Date.now();
-
-  if (status != 'O' || cutoffTime < nowTime || !contestants?.length) {
-    return null;
-  }
-
-  const events = {};
-  contestants.forEach(contestant => {
-    const { name, price, max } = contestant;
-    const nr = name.match(/\d+$/)?.[0];
-    if (!nr) {
-      return;
-    }
-    let side;
-    if (name.startsWith(home)) {
-      side = 'h';
-    }
-    else if (name.startsWith(away)) {
-      side = 'c';
-    }
-    else {
-      return;
-    }
-    events[`ior_wm${side}_${nr}`] = { v: price, m: max };
-  });
-  return events;
-}
-
-/**
- * 解析进球数盘口
- * @param {*} exactTotalGoals
- * @returns
- */
-const parseExactTotalGoals = (exactTotalGoals) => {
-  if (!exactTotalGoals) {
-    return null;
-  }
-  const { cutoff='', status='', contestants=[] } = exactTotalGoals;
-  const cutoffTime = new Date(cutoff).getTime();
-  const nowTime = Date.now();
-
-  if (status != 'O' || cutoffTime < nowTime || !contestants?.length) {
-    return null;
-  }
-
-  const events = {};
-  contestants.forEach(contestant => {
-    const { name, price, max } = contestant;
-    if (+name >= 1 && +name <= 7) {
-      events[`ior_ot_${name}`] = { v: price, m: max };
-    }
-  });
-  return events;
-}
-
-/**
- * 解析滚球场次状态
- * @param {*} state
- * @returns
- */
-const parseRbState = (state) => {
-  let stage = null;
-  if (state == 1) {
-    stage = '1H';
-  }
-  else if (state == 2) {
-    stage = 'HT';
-  }
-  else if (state == 3) {
-    stage = '2H';
-  }
-  else if (state >= 4) {
-    stage = 'ET';
-  }
-  return stage;
-}
-
-/**
- * 解析比赛数据
- * @param {*} game
- * @returns
- */
-const parseGame = (game) => {
-  const { id=0, originId=0, periods={}, specials={}, home, away, marketType, state, elapsed, homeScore=0, awayScore=0 } = game;
-  const { straight } = periods;
-  const { winningMargin={}, exactTotalGoals={} } = specials;
-  const wm = homeScore - awayScore;
-  const score = `${homeScore}-${awayScore}`;
-  const odds = parseStraight(straight, wm) ?? {};
-  const stage = parseRbState(state);
-  const retime = elapsed ? `${elapsed}'` : '';
-  const evtime = Date.now();
-  Object.assign(odds, parseWinningMargin(winningMargin, home, away));
-  Object.assign(odds, parseExactTotalGoals(exactTotalGoals));
-  return { id, originId, odds, evtime, stage, retime, score, wm, marketType }
-}
-
-export default parseGame;

+ 0 - 321
pinnacle/libs/pinnacleClient.js

@@ -1,321 +0,0 @@
-import dotenv from 'dotenv';
-import axios from "axios";
-import { HttpsProxyAgent } from "https-proxy-agent";
-
-import { randomUUID } from 'crypto';
-
-import Logs from "./logs.js";
-import getDateInTimezone from "./getDateInTimezone.js";
-
-dotenv.config();
-
-const axiosDefaultOptions = {
-  baseURL: "",
-  url: "",
-  method: "GET",
-  headers: {},
-  params: {},
-  data: {},
-  timeout: 10000,
-};
-
-const pinnacleWebOptions = {
-  ...axiosDefaultOptions,
-  baseURL: "https://www.part987.com",
-  headers: {
-    "accept": "application/json, text/plain, */*",
-    "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
-    "x-app-data": "directusToken=TwEdnphtyxsfMpXoJkCkWaPsL2KJJ3lo;lang=zh_CN;dpVXz=ZDfaFZUP9"
-  },
-}
-
-export const pinnacleRequest = async (options, channel) => {
-
-  const { url, ...optionsRest } = options;
-  const username = process.env.PINNACLE_USERNAME;
-  const password = process.env.PINNACLE_PASSWORD;
-  if (!url || !channel && (!username || !password)) {
-    throw new Error("url、username、password、channel is required");
-  }
-
-  Logs.outDev('pinnacle request', url, channel, username, password);
-
-  const authHeader = channel ? `Basic ${channel}` : `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
-  const axiosConfig = { ...axiosDefaultOptions, ...optionsRest, url, baseURL: "https://api.pinnacle888.com" };
-  Object.assign(axiosConfig.headers, {
-    "Authorization": authHeader,
-    "Accept": "application/json",
-  });
-  const proxy = process.env.NODE_HTTP_PROXY;
-  if (proxy) {
-    axiosConfig.proxy = false;
-    axiosConfig.httpsAgent = new HttpsProxyAgent(proxy);
-  }
-  return axios(axiosConfig).then(res => {
-    // Logs.out('pinnacle request', url, axiosConfig, res.data);
-    return res.data;
-  });
-}
-
-/**
- * Pinnacle API Get请求
- * @param {*} url
- * @param {*} params
- * @returns
- */
-export const pinnacleGet = async (url, params, channel) => {
-  return pinnacleRequest({
-    url,
-    params
-  }, channel)
-  .catch(err => {
-    const source = { url, params };
-    if (err?.response?.data) {
-      const data = err.response.data;
-      Object.assign(source, { data });
-    }
-    err.source = source;
-    return Promise.reject(err);
-  });
-}
-
-/**
- * Pinnacle API Post请求
- * @param {*} url
- * @param {*} data
- * @returns
- */
-export const pinnaclePost = async (url, data, channel) => {
-  return pinnacleRequest({
-    url,
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-    },
-    data
-  }, channel);
-}
-
-/**
- * 清理对象中的undefined值
- * @param {*} obj
- * @returns {Object}
- */
-const cleanUndefined = (obj) => {
-  return Object.fromEntries(
-    Object.entries(obj).filter(([, v]) => v !== undefined)
-  );
-}
-
-/**
- * 获取直盘线
- */
-export const getLineInfo = async (info = {}, channel) => {
-  const {
-    leagueId, eventId, betType, handicap, team, side,
-    specialId, contestantId,
-    periodNumber=0, oddsFormat='Decimal', sportId=29
-  } = info;
-  let url = '/v2/line/';
-  let data = { sportId, leagueId, eventId, betType, handicap, periodNumber, team, side, oddsFormat };
-  if (specialId) {
-    url = '/v2/line/special';
-    data = { specialId, contestantId, oddsFormat };
-  }
-  data = cleanUndefined(data);
-  return pinnacleGet(url, data, channel)
-  .then(ret => ({ info: ret, line: data }))
-  .catch(err => {
-    Logs.errDev('get line info error', err);
-    err.data = err.response.data;
-    err.cause = err.response.status;
-    return Promise.reject(err);
-  });
-}
-
-/**
- * 获取账户余额
- */
-export const getAccountBalance = async () => {
-  return pinnacleGet('/v1/client/balance');
-}
-
-/**
- * 下注
- */
-export const placeOrder = async ({ info, line, stakeSize }, channel) => {
-  // return Promise.resolve({info, line, stakeSize});
-  const uuid = randomUUID()
-  if (line.specialId) {
-    const data = cleanUndefined({
-      oddsFormat: line.oddsFormat,
-      uniqueRequestId: uuid,
-      acceptBetterLine: true,
-      stake: stakeSize,
-      winRiskStake: 'RISK',
-      lineId: info.lineId,
-      specialId: info.specialId,
-      contestantId: info.contestantId,
-    });
-    Logs.outDev('pinnacle place order data', data);
-    return pinnaclePost('/v4/bets/special', { bets: [data] }, channel)
-    .then(ret => ret.bets?.[0] ?? ret)
-    .then(ret => {
-      Logs.outDev('pinnacle place order', ret, uuid);
-      return ret;
-    })
-    .catch(err => {
-      Logs.outDev('pinnacle place order error', err.response.data, uuid);
-      Logs.errDev(err);
-      err.data = err.response.data;
-      err.cause = err.response.status;
-      return Promise.reject(err);
-    });
-  }
-  else {
-    const data = cleanUndefined({
-      oddsFormat: line.oddsFormat,
-      uniqueRequestId: uuid,
-      acceptBetterLine: true,
-      stake: stakeSize,
-      winRiskStake: 'RISK',
-      lineId: info.lineId,
-      altLineId: info.altLineId,
-      fillType: 'NORMAL',
-      sportId: line.sportId,
-      eventId: line.eventId,
-      periodNumber: line.periodNumber,
-      betType: line.betType,
-      team: line.team,
-      side: line.side,
-      handicap: line.handicap,
-    });
-    Logs.outDev('pinnacle place order data', data);
-    return pinnaclePost('/v4/bets/place', data, channel)
-    .then(ret => {
-      Logs.outDev('pinnacle place order', ret, uuid);
-      return ret;
-    })
-    .catch(err => {
-      Logs.outDev('pinnacle place order error', err.response.data, uuid);
-      Logs.errDev(err);
-      err.data = err.response.data;
-      err.cause = err.response.status;
-      return Promise.reject(err);
-    });
-  }
-}
-
-/**
- * 获取Web端联赛数据
- * @param {*} marketType 0: 早盘赛事, 1: 今日赛事
- * @param {*} locale en_US or zh_CN
- * @returns
- */
-export const pinnacleWebLeagues = async (marketType = 1, locale = "zh_CN") => {
-  const dateString = marketType == 1 ? getDateInTimezone(-4) : getDateInTimezone(-4, Date.now()+24*60*60*1000);
-  const nowTime = Date.now();
-  const axiosConfig = {
-    ...pinnacleWebOptions,
-    url: "/sports-service/sv/compact/leagues",
-    params: {
-      btg: 1, c: "", d: dateString,
-      l: true,  mk: marketType,
-      pa: 0, pn: -1, sp: 29, tm: 0,
-      locale, _: nowTime, withCredentials: true
-    },
-  };
-  const proxy = process.env.NODE_HTTP_PROXY;
-  if (proxy) {
-    axiosConfig.proxy = false;
-    axiosConfig.httpsAgent = new HttpsProxyAgent(proxy);
-  }
-  return axios(axiosConfig).then(res => res.data?.[0]?.[2]?.map(item => {
-    const [ id, , name ] = item;
-    return [id, { id, name }];
-  }) ?? []);
-}
-
-/**
- * 获取Web比赛列表
- */
-export const pinnacleWebGames = async (leagues=[], marketType=1, locale="zh_CN") => {
-  const dateString = marketType == 1 ? getDateInTimezone(-4) : getDateInTimezone(-4, Date.now()+24*60*60*1000);
-  const nowTime = Date.now();
-  const axiosConfig = {
-    ...pinnacleWebOptions,
-    url: "/sports-service/sv/odds/events",
-    params: {
-      sp: 29, lg: leagues.join(','), ev: "",
-      mk: marketType, btg: 1, ot: 1,
-      d: dateString, o: 0, l: 100, v: 0,
-      me: 0, more: false, tm: 0, pa: 0, c: "",
-      g: "QQ==", cl: 100, pimo: "0,1,8,39,2,3,6,7,4,5",
-      inl: false, _: nowTime, locale
-    },
-  };
-  const proxy = process.env.NODE_HTTP_PROXY;
-  if (proxy) {
-    axiosConfig.proxy = false;
-    axiosConfig.httpsAgent = new HttpsProxyAgent(proxy);
-  }
-  return axios(axiosConfig).then(res => res.data.n?.[0]?.[2]?.map(league => {
-    const [leagueId, leagueName, games] = league;
-    return games.map(game => {
-      const [id, teamHomeName, teamAwayName, , timestamp] = game;
-      const startTime = getDateInTimezone('+8', timestamp, true);
-      return { id, leagueId, leagueName, teamHomeName, teamAwayName, timestamp, startTime }
-    })
-  }).flat().filter(game => {
-    const { teamHomeName, teamAwayName } = game;
-    if (teamHomeName.startsWith('主队')) {
-      return false;
-    }
-    else if (teamAwayName.startsWith('客队')) {
-      return false;
-    }
-    else if (teamHomeName.startsWith('Home Teams')) {
-      return false;
-    }
-    else if (teamAwayName.startsWith('Away Teams')) {
-      return false;
-    }
-    return true;
-  }) ?? []);
-}
-
-export const platformRequest = async (options) => {
-  const { url } = options;
-  if (!url) {
-    throw new Error("url is required");
-  }
-  const mergedOptions = {
-    ...axiosDefaultOptions,
-    ...options,
-    baseURL: "http://127.0.0.1:9020",
-  };
-  return axios(mergedOptions).then(res => res.data);
-}
-
-export const platformPost = async (url, data) => {
-  return platformRequest({
-    url,
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-    },
-    data,
-  });
-}
-
-export const platformGet = async (url, params) => {
-  return platformRequest({ url, method: 'GET', params });
-}
-
-/**
- * 通知异常
- * @param {*} message
- */
-export const notifyException = async (message) => {
-  Logs.out('notify exception', message);
-}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 401
pinnacle/linesapi_zh.html


+ 0 - 771
pinnacle/main.js

@@ -1,771 +0,0 @@
-import path from "path";
-import { fileURLToPath } from "url";
-
-import Logs from "./libs/logs.js";
-import { getData, setData } from "./libs/cache.js";
-import getDateInTimezone from "./libs/getDateInTimezone.js";
-
-import {
-  pinnacleGet,
-  pinnacleWebLeagues, pinnacleWebGames,
-  platformPost, platformGet,
-  notifyException,
-} from "./libs/pinnacleClient.js";
-import parseGame from "./libs/parseGameData.js";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-const gamesMapCacheFile = path.join(__dirname, './cache/pinnacleGamesCache.json');
-const globalDataCacheFile = path.join(__dirname, './cache/pinnacleGlobalDataCache.json');
-
-const GLOBAL_DATA = {
-  relatedLeagues: [],
-  selectedLeagues: [],
-  relatedGames: [],
-  gamesMap: {},
-  straightFixturesVersion: 0,
-  straightFixturesCount: 0,
-  specialFixturesVersion: 0,
-  specialFixturesCount: 0,
-  straightOddsVersion: 0,
-  // straightOddsCount: 0,
-  specialsOddsVersion: 0,
-  // specialsOddsCount: 0,
-  requestErrorCount: 0,
-  loopActive: false,
-  loopLastResultTime: 0,
-  wsClientData: null,
-};
-
-const resetVersionsCount = () => {
-  GLOBAL_DATA.straightFixturesVersion = 0;
-  GLOBAL_DATA.specialFixturesVersion = 0;
-  GLOBAL_DATA.straightOddsVersion = 0;
-  GLOBAL_DATA.specialsOddsVersion = 0;
-
-  GLOBAL_DATA.straightFixturesCount = 0;
-  GLOBAL_DATA.specialFixturesCount = 0;
-}
-
-const incrementVersionsCount = () => {
-  GLOBAL_DATA.straightFixturesCount += 1;
-  GLOBAL_DATA.specialFixturesCount += 1;
-}
-
-/**
- * 更新Web联赛列表
- */
-const updateWebLeagues = async () => {
-  const getWebLeaguesToday = pinnacleWebLeagues(1);
-  const getWebLeaguesTomorrow = pinnacleWebLeagues(0);
-  return Promise.all([getWebLeaguesToday, getWebLeaguesTomorrow])
-  .then(data => {
-    const [webLeaguesToday, webLeaguesTomorrow] = data;
-    const leaguesMap = new Map(webLeaguesToday.concat(webLeaguesTomorrow));
-    return Array.from(leaguesMap.values()).sort((a, b) => a.id - b.id);
-  })
-  .then(leagues => {
-    Logs.outDev('update leagues list', leagues.length);
-    return platformPost('/api/platforms/update_leagues', { platform: 'pinnacle', leagues });
-  })
-  .then(() => {
-    Logs.outDev('leagues list updated');
-    return Promise.resolve({ delay: 60 });
-  });
-}
-
-/**
- * 定时更新Web联赛列表
- */
-const updateWebLeaguesLoop = () => {
-  updateWebLeagues()
-  .then(data => {
-    const { delay=5 } = data ?? {};
-    setTimeout(() => {
-      updateWebLeaguesLoop();
-    }, 1000 * delay);
-  })
-  .catch(err => {
-    Logs.err('failed to update leagues list', err.message);
-    setTimeout(() => {
-      updateWebLeaguesLoop();
-    }, 1000 * 5);
-  });
-}
-
-/**
- * 更新Web比赛列表
- */
-const updateWebGames = async () => {
-  if (!GLOBAL_DATA.relatedLeagues.length) {
-    return Promise.resolve({ delay: 5 });
-  }
-  const getWebGamesToday = pinnacleWebGames(GLOBAL_DATA.relatedLeagues, 1);
-  const getWebGamesTomorrow = pinnacleWebGames(GLOBAL_DATA.relatedLeagues, 0);
-  return Promise.all([getWebGamesToday, getWebGamesTomorrow])
-  .then(data => {
-    const [webGamesToday, webGamesTomorrow] = data;
-    return webGamesToday.concat(webGamesTomorrow).sort((a, b) => a.timestamp - b.timestamp);
-  })
-  .then(games => {
-    Logs.outDev('update games list', games.length);
-    return platformPost('/api/platforms/update_games', { platform: 'pinnacle', games });
-  })
-  .then(() => {
-    Logs.outDev('games list updated');
-    return Promise.resolve({ delay: 60 });
-  });
-}
-
-/**
- * 定时更新Web比赛列表
- */
-const updateWebGamesLoop = () => {
-  updateWebGames()
-  .then(data => {
-    const { delay=5 } = data ?? {};
-    setTimeout(() => {
-      updateWebGamesLoop();
-    }, 1000 * delay);
-  })
-  .catch(err => {
-    Logs.err('failed to update games list', err.message);
-    setTimeout(() => {
-      updateWebGamesLoop();
-    }, 1000 * 5);
-  });
-}
-
-/**
- * 获取过滤后的联赛数据
- * @returns
- */
-const updateRelatedLeagues = () => {
-  platformGet('/api/platforms/get_related_leagues', { platform: 'pinnacle' })
-  .then(res => {
-    const { data: relatedLeagues } = res;
-    GLOBAL_DATA.relatedLeagues = relatedLeagues.map(item => item.id);
-  })
-  .catch(error => {
-    Logs.err('failed to update filtered leagues', error.message);
-  })
-  .finally(() => {
-    setTimeout(() => {
-      updateRelatedLeagues();
-    }, 1000 * 10);
-  });
-}
-
-/**
- * 获取过滤后的比赛数据
- * @returns
- */
-const updateRelatedGames = () => {
-  platformGet('/api/platforms/get_related_games', { platform: 'pinnacle' })
-  .then(res => {
-    const { data: relatedGames } = res;
-    GLOBAL_DATA.relatedGames = relatedGames.map(item => item.id);
-    GLOBAL_DATA.selectedLeagues = [...new Set(relatedGames.map(item => item.leagueId))];
-  })
-  .catch(error => {
-    Logs.err('failed to update related games', error.message);
-  })
-  .finally(() => {
-    setTimeout(() => {
-      updateRelatedGames();
-    }, 1000 * 10);
-  });
-}
-
-/**
- * 获取直赛数据
- * @returns
- */
-const getStraightFixtures = async () => {
-  if (!GLOBAL_DATA.selectedLeagues.length) {
-    // resetVersionsCount();
-    GLOBAL_DATA.straightFixturesVersion = 0;
-    return Promise.resolve({ games: [], update: 'full' });
-    // return Promise.reject(new Error('no related leagues', { cause: 400 }));
-  }
-  const leagueIds = GLOBAL_DATA.selectedLeagues.join(',');
-  let since = GLOBAL_DATA.straightFixturesVersion;
-  if (GLOBAL_DATA.straightFixturesCount >= 12) {
-    since = 0;
-    GLOBAL_DATA.straightFixturesCount = 0;
-  }
-  if (since == 0) {
-    Logs.outDev('full update straight fixtures');
-  }
-  return pinnacleGet('/v3/fixtures', { sportId: 29, leagueIds, since })
-  .then(data => {
-    const { league, last } = data;
-    if (!last) {
-      return {};
-    }
-    GLOBAL_DATA.straightFixturesVersion = last;
-    const games = league?.map(league => {
-      const { id: leagueId, events } = league;
-      return events?.map(event => {
-        const { starts } = event;
-        const timestamp = new Date(starts).getTime();
-        return { leagueId, ...event, timestamp };
-      });
-    })
-    .flat() ?? [];
-    const update = since == 0 ? 'full' : 'increment';
-    return { games, update };
-  });
-}
-
-/**
- * 更新直赛数据
- * @returns
- */
-const updateStraightFixtures = async () => {
-  return getStraightFixtures()
-  .then(data => {
-    const { games, update } = data ?? {};
-    const { gamesMap } = GLOBAL_DATA;
-    if (games?.length) {
-      games.forEach(game => {
-        const { id } = game;
-        if (!gamesMap[id]) {
-          gamesMap[id] = game;
-        }
-        else {
-          Object.assign(gamesMap[id], game);
-        }
-      });
-    }
-    if (update && update == 'full') {
-      const gamesSet = new Set(games.map(game => game.id));
-      Object.keys(gamesMap).forEach(key => {
-        if (!gamesSet.has(+key)) {
-          delete gamesMap[key];
-        }
-      });
-    }
-    return Promise.resolve('StraightFixtures');
-  });
-}
-
-/**
- * 获取直赛赔率数据
- * @returns
- */
-const getStraightOdds = async () => {
-  if (!GLOBAL_DATA.selectedLeagues.length) {
-    // resetVersionsCount();
-    GLOBAL_DATA.straightOddsVersion = 0;
-    return Promise.resolve([]);
-    // return Promise.reject(new Error('no related leagues', { cause: 400 }));
-  }
-  const leagueIds = GLOBAL_DATA.selectedLeagues.join(',');
-  const since = GLOBAL_DATA.straightOddsVersion;
-  // if (GLOBAL_DATA.straightOddsCount >= 27) {
-  //   since = 0;
-  //   GLOBAL_DATA.straightOddsCount = 3;
-  // }
-  if (since == 0) {
-    Logs.outDev('full update straight odds');
-  }
-  return pinnacleGet('/v3/odds', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since })
-  .then(data => {
-    const { leagues, last } = data;
-    if (!last) {
-      return [];
-    }
-    GLOBAL_DATA.straightOddsVersion = last;
-    const games = leagues?.flatMap(league => league.events);
-    // return games;
-    return games?.map(item => {
-      const { periods, ...rest } = item;
-      const straight = periods?.find(period => period.number == 0);
-      return { ...rest, periods: { straight }};
-    }) ?? [];
-  });
-}
-
-/**
- * 更新直赛赔率数据
- * @returns
- */
-const updateStraightOdds = async () => {
-  return getStraightOdds()
-  .then(games => {
-    if (games?.length) {
-      const { gamesMap } = GLOBAL_DATA;
-      games.forEach(game => {
-        const { id, periods, ...rest } = game;
-        const localGame = gamesMap[id];
-        if (localGame) {
-          Object.assign(localGame, rest);
-          if (!localGame.periods && periods) {
-            localGame.periods = periods;
-          }
-          else if (periods) {
-            Object.assign(localGame.periods, periods);
-          }
-        }
-      });
-    }
-    return Promise.resolve('StraightOdds');
-  });
-}
-
-/**
- * 获取特殊赛数据
- * @returns
- */
-const getSpecialFixtures = async () => {
-  if (!GLOBAL_DATA.selectedLeagues.length) {
-    // resetVersionsCount();
-    GLOBAL_DATA.specialFixturesVersion = 0;
-    return Promise.resolve({ specials: [], update: 'full' });
-    // return Promise.reject(new Error('no related leagues', { cause: 400 }));
-  }
-  const leagueIds = GLOBAL_DATA.selectedLeagues.join(',');
-  let since = GLOBAL_DATA.specialFixturesVersion;
-  if (GLOBAL_DATA.specialFixturesCount >= 18) {
-    since = 0;
-    GLOBAL_DATA.specialFixturesCount = 6;
-  }
-  if (since == 0) {
-    Logs.outDev('full update special fixtures');
-  }
-  return pinnacleGet('/v2/fixtures/special', { sportId: 29, leagueIds, since })
-  .then(data => {
-    const { leagues, last } = data;
-    if (!last) {
-      return [];
-    }
-    GLOBAL_DATA.specialFixturesVersion = last;
-    const specials = leagues?.map(league => {
-      const { specials } = league;
-      return specials?.filter(special => special.event)
-      .map(special => {
-        const { event: { id: eventId, periodNumber }, ...rest } = special ?? { event: {} };
-        return { eventId, periodNumber, ...rest };
-      }) ?? [];
-    })
-    .flat()
-    .filter(special => {
-      if (special.name.includes('Winning Margin') || special.name.includes('Exact Total Goals')) {
-        return true;
-      }
-      return false;
-    }) ?? [];
-    const update = since == 0 ? 'full' : 'increment';
-    return { specials, update };
-  });
-}
-
-/**
- * 合并特殊赛盘口数据
- * @param {*} localContestants
- * @param {*} remoteContestants
- * @returns
- */
-const mergeContestants = (localContestants=[], remoteContestants=[]) => {
-  const localContestantsMap = new Map(localContestants.map(contestant => [contestant.id, contestant]));
-  remoteContestants.forEach(contestant => {
-    const localContestant = localContestantsMap.get(contestant.id);
-    if (localContestant) {
-      Object.assign(localContestant, contestant);
-    }
-    else {
-      localContestants.push(contestant);
-    }
-  });
-  const remoteContestantsMap = new Map(remoteContestants.map(contestant => [contestant.id, contestant]));
-  for (let i = localContestants.length - 1; i >= 0; i--) {
-    if (!remoteContestantsMap.has(localContestants[i].id)) {
-      localContestants.splice(i, 1);
-    }
-  }
-  return localContestants;
-}
-
-/**
- * 合并特殊赛数据
- * @param {*} localSpecials
- * @param {*} remoteSpecials
- * @param {*} specialName
- * @returns
- */
-const mergeSpecials = (localSpecials, remoteSpecials, specialName) => {
-  if (localSpecials[specialName] && remoteSpecials[specialName]) {
-    const { contestants: specialContestants, ...specialRest } = remoteSpecials[specialName];
-    Object.assign(localSpecials[specialName], specialRest);
-    mergeContestants(localSpecials[specialName].contestants, specialContestants);
-  }
-  else if (remoteSpecials[specialName]) {
-    localSpecials[specialName] = remoteSpecials[specialName];
-  }
-}
-
-/**
- * 更新特殊赛数据
- * @returns
- */
-const updateSpecialFixtures = async () => {
-  return getSpecialFixtures()
-  .then(data => {
-    const { specials, update } = data ?? {};
-    if (specials?.length) {
-      const { gamesMap } = GLOBAL_DATA;
-      const gamesSpecialsMap = {};
-      specials.forEach(special => {
-        const { eventId } = special;
-        if (!gamesSpecialsMap[eventId]) {
-          gamesSpecialsMap[eventId] = {};
-        }
-        if (special.name == 'Winning Margin') {
-          gamesSpecialsMap[eventId].winningMargin = special;
-        }
-        else if (special.name == 'Exact Total Goals') {
-          gamesSpecialsMap[eventId].exactTotalGoals = special;
-        }
-      });
-
-      Object.keys(gamesSpecialsMap).forEach(eventId => {
-        if (!gamesMap[eventId]) {
-          return;
-        }
-
-        if (!gamesMap[eventId].specials) {
-          gamesMap[eventId].specials = {};
-        }
-
-        const localSpecials = gamesMap[eventId].specials;
-        const remoteSpecials = gamesSpecialsMap[eventId];
-
-        mergeSpecials(localSpecials, remoteSpecials, 'winningMargin');
-        mergeSpecials(localSpecials, remoteSpecials, 'exactTotalGoals');
-
-      });
-    }
-    return Promise.resolve('SpecialFixtures');
-  });
-}
-
-/**
- * 获取特殊赛赔率数据
- * @returns
- */
-const getSpecialsOdds = async () => {
-  if (!GLOBAL_DATA.selectedLeagues.length) {
-    // resetVersionsCount();
-    GLOBAL_DATA.specialsOddsVersion = 0;
-    return Promise.resolve([]);
-    // return Promise.reject(new Error('no related leagues', { cause: 400 }));
-  }
-  const leagueIds = GLOBAL_DATA.selectedLeagues.join(',');
-  const since = GLOBAL_DATA.specialsOddsVersion;
-  // if (GLOBAL_DATA.specialsOddsCount >= 33) {
-  //   since = 0;
-  //   GLOBAL_DATA.specialsOddsCount = 9;
-  // }
-  if (since == 0) {
-    Logs.outDev('full update specials odds');
-  }
-  return pinnacleGet('/v2/odds/special', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since })
-  .then(data => {
-    const { leagues, last } = data;
-    if (!last) {
-      return [];
-    }
-    GLOBAL_DATA.specialsOddsVersion = last;
-    return leagues?.flatMap(league => league.specials);
-  });
-}
-
-/**
- * 更新特殊赛赔率数据
- * @returns
- */
-const updateSpecialsOdds = async () => {
-  return getSpecialsOdds()
-  .then(specials => {
-    if (specials?.length) {
-      const { gamesMap } = GLOBAL_DATA;
-      const contestants = Object.values(gamesMap)
-      .filter(game => game.specials)
-      .map(game => {
-        const { specials } = game;
-        const contestants = Object.values(specials).map(special => {
-          const { contestants } = special;
-          return contestants.map(contestant => [contestant.id, contestant]);
-        });
-        return contestants;
-      }).flat(2);
-      const contestantsMap = new Map(contestants);
-      const lines = specials.flatMap(special => special.contestantLines);
-      lines.forEach(line => {
-        const { id, handicap, lineId, max, price } = line;
-        const contestant = contestantsMap.get(id);
-        if (!contestant) {
-          return;
-        }
-        contestant.handicap = handicap;
-        contestant.lineId = lineId;
-        contestant.max = max;
-        contestant.price = price;
-      });
-    }
-    return Promise.resolve('SpecialsOdds');
-  });
-}
-
-/**
- * 获取滚球数据
- * @returns
- */
-const getInRunning = async () => {
-  return pinnacleGet('/v2/inrunning')
-  .then(data => {
-    const sportId = 29;
-    const leagues = data.sports?.find(sport => sport.id == sportId)?.leagues ?? [];
-    return leagues.filter(league => {
-      if (!GLOBAL_DATA.selectedLeagues.length) {
-        return true;
-      }
-      const { id } = league;
-      const selectedLeaguesSet = new Set(GLOBAL_DATA.selectedLeagues);
-      return selectedLeaguesSet.has(id);
-    }).flatMap(league => league.events);
-  });
-}
-
-/**
- * 更新滚球数据
- * @returns
- */
-const updateInRunning = async () => {
-  return getInRunning()
-  .then(games => {
-    if (!games.length) {
-      return Promise.resolve('InRunning');
-    }
-    const { gamesMap } = GLOBAL_DATA;
-    games.forEach(game => {
-      const { id, state, elapsed } = game;
-      const localGame = gamesMap[id];
-      if (localGame) {
-        Object.assign(localGame, { state, elapsed });
-      }
-    });
-    return Promise.resolve('InRunning');
-  });
-}
-
-/**
- * 获取比赛盘口赔率数据
- * @returns {Object}
- */
-const getGamesEvents = () => {
-  const { relatedGames, gamesMap } = GLOBAL_DATA;
-  const relatedGamesSet = new Set(relatedGames);
-  const nowTime = Date.now();
-  const gamesData = {};
-  Object.values(gamesMap).forEach(game => {
-    const { id, liveStatus, parentId, resultingUnit, timestamp } = game;
-
-    if (resultingUnit !== 'Regular') {
-      return false;  // 非常规赛事不处理
-    }
-
-    const gmtMinus4Date = getDateInTimezone(-4);
-    const todayEndTime = new Date(`${gmtMinus4Date} 23:59:59 GMT-4`).getTime();
-    const tomorrowEndTime = todayEndTime + 24 * 60 * 60 * 1000;
-    if (liveStatus == 1 && timestamp < nowTime) {
-      game.marketType = 2;  // 滚球赛事
-    }
-    else if (liveStatus != 1 && timestamp > nowTime && timestamp <= todayEndTime) {
-      game.marketType = 1;  // 今日赛事
-    }
-    else if (liveStatus != 1 && timestamp > todayEndTime && timestamp <= tomorrowEndTime) {
-      game.marketType = 0;  // 明日早盘赛事
-    }
-    else {
-      game.marketType = -1;  // 非近期赛事
-    }
-
-    if (game.marketType < 0) {
-      return false;  // 非近期赛事不处理
-    }
-
-    let actived = false;
-    if (liveStatus != 1 && (relatedGamesSet.has(id) || !relatedGames.length)) {
-      actived = true;  // 在赛前列表中
-      game.id = id;
-      game.originId = 0;
-    }
-    else if (liveStatus == 1 && (relatedGamesSet.has(parentId) || !relatedGames.length)) {
-      actived = true;  // 在滚球列表中
-      game.id = parentId;
-      game.originId = id;
-    }
-
-    if (actived) {
-      const { marketType, ...rest } = parseGame(game);
-      if (!gamesData[marketType]) {
-        gamesData[marketType] = [];
-      }
-      gamesData[marketType].push(rest);
-    }
-  });
-  const games = Object.values(gamesData).flat();
-  const timestamp = nowTime;
-  const data = { games, timestamp };
-  return data;
-}
-
-/**
- * 更新赔率数据
- */
-const updateOdds = async () => {
-  const { games, timestamp } = getGamesEvents();
-  return platformPost('/api/platforms/update_odds', { platform: 'pinnacle', games, timestamp });
-}
-
-
-const pinnacleDataLoop = () => {
-  updateStraightFixtures()
-  .then(() => {
-    return Promise.all([
-      updateStraightOdds(),
-      updateSpecialFixtures(),
-      updateInRunning(),
-    ]);
-  })
-  .then(() => {
-    return updateSpecialsOdds();
-  })
-  .then(() => {
-    if (!GLOBAL_DATA.loopActive) {
-      GLOBAL_DATA.loopActive = true;
-      Logs.out('loop active');
-      notifyException('Pinnacle API startup.');
-    }
-
-    if (GLOBAL_DATA.requestErrorCount > 0) {
-      GLOBAL_DATA.requestErrorCount = 0;
-      Logs.out('request error count reset');
-    }
-
-    const nowTime = Date.now();
-    const loopDuration = nowTime - GLOBAL_DATA.loopLastResultTime;
-    GLOBAL_DATA.loopLastResultTime = nowTime;
-    if (loopDuration > 15000) {
-      Logs.out('loop duration is too long', loopDuration);
-    }
-    else {
-      Logs.outDev('loop duration', loopDuration);
-    }
-    setData(gamesMapCacheFile, GLOBAL_DATA.gamesMap);
-
-    return updateOdds();
-  })
-  .catch(err => {
-    Logs.err(err.message, err.source);
-    GLOBAL_DATA.requestErrorCount++;
-    if (GLOBAL_DATA.loopActive && GLOBAL_DATA.requestErrorCount > 5) {
-      const exceptionMessage = 'request errors have reached the limit';
-      Logs.out(exceptionMessage);
-      GLOBAL_DATA.loopActive = false;
-
-      Logs.out('loop inactive');
-      notifyException(`Pinnacle API paused. ${exceptionMessage}. ${err.message}`);
-    }
-  })
-  .finally(() => {
-    const { loopActive, selectedLeagues } = GLOBAL_DATA;
-    let loopDelay = 5_000;
-    if (!loopActive) {
-      loopDelay = 60_000;
-      resetVersionsCount();
-    }
-    else if (!selectedLeagues.length) {
-      resetVersionsCount();
-    }
-    else {
-      incrementVersionsCount();
-    }
-    setTimeout(pinnacleDataLoop, loopDelay);
-  });
-}
-
-
-/**
- * 缓存GLOBAL_DATA数据到文件
- */
-const saveGlobalDataToCache = async () => {
-  return setData(globalDataCacheFile, GLOBAL_DATA);
-}
-
-const loadGlobalDataFromCache = async () => {
-  return getData(globalDataCacheFile)
-  .then(data => {
-    if (!data) {
-      return;
-    }
-    Object.keys(GLOBAL_DATA).forEach(key => {
-      if (key in data) {
-        GLOBAL_DATA[key] = data[key];
-      }
-    });
-  });
-}
-
-const main = () => {
-  if (!process.env.PINNACLE_USERNAME || !process.env.PINNACLE_PASSWORD) {
-    Logs.err('USERNAME or PASSWORD is not set');
-    return;
-  }
-  updateRelatedLeagues();
-  updateRelatedGames();
-  loadGlobalDataFromCache()
-  .then(() => {
-    Logs.out('global data loaded');
-  })
-  .then(() => {
-    updateWebLeaguesLoop();
-    updateWebGamesLoop();
-    pinnacleDataLoop();
-  })
-  .catch(err => {
-    Logs.err('failed to load global data', err.message);
-  })
-  .finally(() => {
-    GLOBAL_DATA.loopLastResultTime = Date.now();
-    GLOBAL_DATA.loopActive = true;
-  });
-}
-
-// 监听进程退出事件,保存GLOBAL_DATA数据
-const saveExit = (code) => {
-  saveGlobalDataToCache()
-  .then(() => {
-    Logs.out('global data saved');
-  })
-  .catch(err => {
-    Logs.err('failed to save global data', err.message);
-  })
-  .finally(() => {
-    process.exit(code);
-  });
-}
-process.on('SIGINT', () => {
-  saveExit(0);
-});
-process.on('SIGTERM', () => {
-  saveExit(0);
-});
-process.on('SIGUSR2', () => {
-  saveExit(0);
-});
-
-main();

+ 0 - 383
pinnacle/package-lock.json

@@ -1,383 +0,0 @@
-{
-  "name": "pinnacle",
-  "version": "1.0.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "pinnacle",
-      "version": "1.0.0",
-      "license": "ISC",
-      "dependencies": {
-        "axios": "^1.13.3",
-        "dayjs": "^1.11.19",
-        "dotenv": "^17.2.3",
-        "https-proxy-agent": "^7.0.6",
-        "ws": "^8.19.0"
-      }
-    },
-    "node_modules/agent-base": {
-      "version": "7.1.4",
-      "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
-      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 14"
-      }
-    },
-    "node_modules/asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "license": "MIT"
-    },
-    "node_modules/axios": {
-      "version": "1.13.3",
-      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.3.tgz",
-      "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==",
-      "license": "MIT",
-      "dependencies": {
-        "follow-redirects": "^1.15.6",
-        "form-data": "^4.0.4",
-        "proxy-from-env": "^1.1.0"
-      }
-    },
-    "node_modules/call-bind-apply-helpers": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
-      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "license": "MIT",
-      "dependencies": {
-        "delayed-stream": "~1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/dayjs": {
-      "version": "1.11.19",
-      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
-      "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
-      "license": "MIT"
-    },
-    "node_modules/debug": {
-      "version": "4.4.3",
-      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
-      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
-      "license": "MIT",
-      "dependencies": {
-        "ms": "^2.1.3"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "peerDependenciesMeta": {
-        "supports-color": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/dotenv": {
-      "version": "17.2.3",
-      "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz",
-      "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://dotenvx.com"
-      }
-    },
-    "node_modules/dunder-proto": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
-      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.1",
-        "es-errors": "^1.3.0",
-        "gopd": "^1.2.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-define-property": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
-      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-errors": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
-      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-object-atoms": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
-      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-set-tostringtag": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
-      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.6",
-        "has-tostringtag": "^1.0.2",
-        "hasown": "^2.0.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/follow-redirects": {
-      "version": "1.15.11",
-      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
-      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/RubenVerborgh"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">=4.0"
-      },
-      "peerDependenciesMeta": {
-        "debug": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/form-data": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
-      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
-      "license": "MIT",
-      "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "es-set-tostringtag": "^2.1.0",
-        "hasown": "^2.0.2",
-        "mime-types": "^2.1.12"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/function-bind": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
-      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/get-intrinsic": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
-      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.2",
-        "es-define-property": "^1.0.1",
-        "es-errors": "^1.3.0",
-        "es-object-atoms": "^1.1.1",
-        "function-bind": "^1.1.2",
-        "get-proto": "^1.0.1",
-        "gopd": "^1.2.0",
-        "has-symbols": "^1.1.0",
-        "hasown": "^2.0.2",
-        "math-intrinsics": "^1.1.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/get-proto": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
-      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
-      "license": "MIT",
-      "dependencies": {
-        "dunder-proto": "^1.0.1",
-        "es-object-atoms": "^1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/gopd": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
-      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/has-symbols": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
-      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/has-tostringtag": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
-      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
-      "license": "MIT",
-      "dependencies": {
-        "has-symbols": "^1.0.3"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/hasown": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
-      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-      "license": "MIT",
-      "dependencies": {
-        "function-bind": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/https-proxy-agent": {
-      "version": "7.0.6",
-      "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
-      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
-      "license": "MIT",
-      "dependencies": {
-        "agent-base": "^7.1.2",
-        "debug": "4"
-      },
-      "engines": {
-        "node": ">= 14"
-      }
-    },
-    "node_modules/math-intrinsics": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
-      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/mime-db": {
-      "version": "1.52.0",
-      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/mime-types": {
-      "version": "2.1.35",
-      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
-      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "license": "MIT",
-      "dependencies": {
-        "mime-db": "1.52.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/ms": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "license": "MIT"
-    },
-    "node_modules/proxy-from-env": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
-      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
-      "license": "MIT"
-    },
-    "node_modules/ws": {
-      "version": "8.19.0",
-      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
-      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.0.0"
-      },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": ">=5.0.2"
-      },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
-      }
-    }
-  }
-}

+ 0 - 20
pinnacle/package.json

@@ -1,20 +0,0 @@
-{
-  "name": "pinnacle",
-  "version": "1.0.0",
-  "description": "",
-  "main": "main.js",
-  "type": "module",
-  "scripts": {
-    "dev": "nodemon --ignore data/ --ignore cache/ --ignore node_modules/ --inspect=9228 main.js",
-    "start": "pm2 start main.js --name ppai-pinnacle"
-  },
-  "author": "",
-  "license": "ISC",
-  "dependencies": {
-    "axios": "^1.13.3",
-    "dayjs": "^1.11.19",
-    "dotenv": "^17.2.3",
-    "https-proxy-agent": "^7.0.6",
-    "ws": "^8.19.0"
-  }
-}

+ 58 - 0
polymarket/apikey.js

@@ -0,0 +1,58 @@
+import 'dotenv/config';
+
+import axios from "axios";
+import { HttpsProxyAgent } from "https-proxy-agent";
+import { Chain, ClobClient } from "@polymarket/clob-client-v2";
+import { createWalletClient, http } from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+import { polygon } from "viem/chains";
+
+const NODE_HTTP_PROXY = process.env.NODE_HTTP_PROXY;
+if (NODE_HTTP_PROXY) {
+  axios.defaults.proxy = false;
+  axios.defaults.httpsAgent = new HttpsProxyAgent(NODE_HTTP_PROXY);
+}
+
+const getRequiredEnv = (key) => {
+  const value = process.env[key];
+  if (!value) {
+    throw new Error(`${key} is required`);
+  }
+  return value;
+}
+
+const normalizePrivateKey = (privateKey) => {
+  return privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
+}
+
+const HOST = "https://clob.polymarket.com";
+const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY")));
+const signer = createWalletClient({ account, chain: polygon, transport: http() });
+
+const client = new ClobClient({
+  host: HOST,
+  chain: Chain.POLYGON,
+  signer,
+  throwOnError: true,
+});
+
+const nonce = process.env.POLYMARKET_API_KEY_NONCE
+  ? Number(process.env.POLYMARKET_API_KEY_NONCE)
+  : undefined;
+
+if (nonce !== undefined && !Number.isInteger(nonce)) {
+  throw new Error(`POLYMARKET_API_KEY_NONCE is invalid: ${process.env.POLYMARKET_API_KEY_NONCE}`);
+}
+
+const apiKey = await client.createApiKey(nonce).catch(error => {
+  if (error?.status === 400) {
+    return client.deriveApiKey(nonce);
+  }
+  throw error;
+});
+
+if (!apiKey?.key || !apiKey?.secret || !apiKey?.passphrase) {
+  throw new Error(`failed to create or derive api key: ${JSON.stringify(apiKey)}`);
+}
+
+console.log(JSON.stringify(apiKey, null, 2));

+ 163 - 0
polymarket/balance.js

@@ -0,0 +1,163 @@
+import 'dotenv/config';
+
+import axios from "axios";
+import { HttpsProxyAgent } from "https-proxy-agent";
+import { AssetType, Chain, ClobClient, SignatureTypeV2 } from "@polymarket/clob-client-v2";
+import { deriveProxyWallet, RelayClient } from "@polymarket/builder-relayer-client";
+import { BuilderConfig } from "@polymarket/builder-signing-sdk";
+import {
+  createPublicClient,
+  createWalletClient,
+  erc20Abi,
+  formatUnits,
+  http,
+} from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+import { polygon } from "viem/chains";
+
+const CHAIN_ID = 137;
+const HOST = "https://clob.polymarket.com";
+const PUSD_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB";
+const PUSD_DECIMALS = 6;
+const PROXY_FACTORY_ADDRESS = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052";
+
+const NODE_HTTP_PROXY = process.env.NODE_HTTP_PROXY;
+const proxyAgent = NODE_HTTP_PROXY ? new HttpsProxyAgent(NODE_HTTP_PROXY) : undefined;
+if (NODE_HTTP_PROXY) {
+  axios.defaults.proxy = false;
+  axios.defaults.httpAgent = proxyAgent;
+  axios.defaults.httpsAgent = proxyAgent;
+}
+
+const getRequiredEnv = (key) => {
+  const value = process.env[key];
+  if (!value) {
+    throw new Error(`${key} is required`);
+  }
+  return value;
+}
+
+const getOptionalEnv = (key) => {
+  const value = process.env[key];
+  return value && value.trim() ? value.trim() : undefined;
+}
+
+const normalizePrivateKey = (privateKey) => {
+  return privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
+}
+
+const getArgValue = (name) => {
+  const prefix = `${name}=`;
+  const arg = process.argv.slice(2).find(item => item.startsWith(prefix));
+  return arg ? arg.slice(prefix.length) : undefined;
+}
+
+const normalizeWalletMode = (wallet) => {
+  const value = (wallet || "both").toLowerCase();
+  if (!["deposit", "proxy", "both"].includes(value)) {
+    throw new Error("wallet must be deposit, proxy, or both");
+  }
+  return value;
+}
+
+const makeClobClient = ({ signer, creds, funderAddress, signatureType }) => {
+  return new ClobClient({
+    host: HOST,
+    chain: Chain.POLYGON,
+    signer,
+    creds,
+    signatureType,
+    funderAddress,
+    throwOnError: true,
+  });
+}
+
+const getChainBalance = async ({ publicClient, address }) => {
+  const balance = await publicClient.readContract({
+    address: PUSD_ADDRESS,
+    abi: erc20Abi,
+    functionName: "balanceOf",
+    args: [address],
+  });
+  return formatUnits(balance, PUSD_DECIMALS);
+}
+
+const getClobBalanceAllowance = async ({ signer, creds, funderAddress, signatureType }) => {
+  const client = makeClobClient({ signer, creds, funderAddress, signatureType });
+  return client.getBalanceAllowance({ asset_type: AssetType.COLLATERAL });
+}
+
+const getDepositWalletAddress = async ({ account, signer, builderConfig }) => {
+  const configuredAddress = getOptionalEnv("POLYMARKET_DEPOSIT_WALLET_ADDRESS");
+  if (configuredAddress) {
+    return configuredAddress;
+  }
+
+  const relayerUrl = getOptionalEnv("POLYMARKET_RELAYER_URL") || "https://relayer-v2.polymarket.com";
+  const relayer = new RelayClient(relayerUrl, CHAIN_ID, signer, builderConfig);
+  if (proxyAgent) {
+    relayer.httpClient.instance.defaults.proxy = false;
+    relayer.httpClient.instance.defaults.httpAgent = proxyAgent;
+    relayer.httpClient.instance.defaults.httpsAgent = proxyAgent;
+  }
+  return relayer.deriveDepositWalletAddress();
+}
+
+const walletMode = normalizeWalletMode(getArgValue("--wallet") || process.env.BALANCE_WALLET);
+const rpcUrl = getOptionalEnv("POLYGON_RPC_URL");
+const transport = rpcUrl ? http(rpcUrl) : http();
+const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY")));
+const signer = createWalletClient({ account, chain: polygon, transport });
+const publicClient = createPublicClient({ chain: polygon, transport });
+const creds = {
+  key: getRequiredEnv("POLYMARKET_API_KEY"),
+  secret: getRequiredEnv("POLYMARKET_API_SECRET"),
+  passphrase: getRequiredEnv("POLYMARKET_API_PASSPHRASE"),
+};
+const builderConfig = new BuilderConfig({
+  localBuilderCreds: {
+    key: getRequiredEnv("POLYMARKET_BUILDER_API_KEY"),
+    secret: getRequiredEnv("POLYMARKET_BUILDER_SECRET"),
+    passphrase: getRequiredEnv("POLYMARKET_BUILDER_PASS_PHRASE"),
+  },
+});
+
+const wallets = [];
+if (walletMode === "proxy" || walletMode === "both") {
+  wallets.push({
+    type: "proxy",
+    address: getOptionalEnv("POLYMARKET_PROXY_WALLET_ADDRESS")
+      || deriveProxyWallet(account.address, PROXY_FACTORY_ADDRESS),
+    signatureType: SignatureTypeV2.POLY_PROXY,
+  });
+}
+if (walletMode === "deposit" || walletMode === "both") {
+  wallets.push({
+    type: "deposit",
+    address: await getDepositWalletAddress({ account, signer, builderConfig }),
+    signatureType: SignatureTypeV2.POLY_1271,
+  });
+}
+
+const results = [];
+for (const wallet of wallets) {
+  const [chainPusdBalance, clobBalanceAllowance] = await Promise.all([
+    getChainBalance({ publicClient, address: wallet.address }),
+    getClobBalanceAllowance({
+      signer,
+      creds,
+      funderAddress: wallet.address,
+      signatureType: wallet.signatureType,
+    }),
+  ]);
+  results.push({
+    type: wallet.type,
+    owner: account.address,
+    address: wallet.address,
+    signatureType: SignatureTypeV2[wallet.signatureType],
+    chainPusdBalance,
+    clobBalanceAllowance,
+  });
+}
+
+console.log(JSON.stringify(results, null, 2));

+ 59 - 0
polymarket/deposit.js

@@ -0,0 +1,59 @@
+import 'dotenv/config';
+
+// import axios from "axios";
+import { HttpsProxyAgent } from "https-proxy-agent";
+import { BuilderConfig } from "@polymarket/builder-signing-sdk";
+import { RelayClient } from "@polymarket/builder-relayer-client";
+import { createWalletClient, http } from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+import { polygon } from "viem/chains";
+
+const NODE_HTTP_PROXY = process.env.NODE_HTTP_PROXY;
+const proxyAgent = NODE_HTTP_PROXY ? new HttpsProxyAgent(NODE_HTTP_PROXY) : undefined;
+// if (NODE_HTTP_PROXY) {
+//   axios.defaults.proxy = false;
+//   axios.defaults.httpAgent = proxyAgent;
+//   axios.defaults.httpsAgent = proxyAgent;
+// }
+
+
+const getRequiredEnv = (key) => {
+  const value = process.env[key];
+  if (!value) {
+    throw new Error(`${key} is required`);
+  }
+  return value;
+}
+
+const normalizePrivateKey = (privateKey) => {
+  return privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
+}
+
+const relayerUrl = process.env.POLYMARKET_RELAYER_URL || "https://relayer-v2.polymarket.com";
+const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY")));
+const signer = createWalletClient({ account, chain: polygon, transport: http() });
+const builderConfig = new BuilderConfig({
+  localBuilderCreds: {
+    key: getRequiredEnv("POLYMARKET_BUILDER_API_KEY"),
+    secret: getRequiredEnv("POLYMARKET_BUILDER_SECRET"),
+    passphrase: getRequiredEnv("POLYMARKET_BUILDER_PASS_PHRASE"),
+  },
+});
+const relayer = new RelayClient(relayerUrl, 137, signer, builderConfig);
+if (proxyAgent) {
+  relayer.httpClient.instance.defaults.proxy = false;
+  relayer.httpClient.instance.defaults.httpAgent = proxyAgent;
+  relayer.httpClient.instance.defaults.httpsAgent = proxyAgent;
+}
+
+const result = {
+  address: await relayer.deriveDepositWalletAddress(),
+  owner: account.address,
+  relayerUrl,
+};
+
+const response = await relayer.deployDepositWallet();
+const confirmed = await response.wait();
+console.log(confirmed);
+console.log(JSON.stringify(result, null, 2));
+console.log(`POLYMARKET_DEPOSIT_WALLET_ADDRESS=${result.address}`);

+ 12 - 0
polymarket/libs/auth.js

@@ -0,0 +1,12 @@
+import crypto from 'node:crypto';
+
+export const safeEqual = (a = '', b = '') => {
+  const aBuffer = Buffer.from(a);
+  const bBuffer = Buffer.from(b);
+
+  if (aBuffer.length !== bBuffer.length) {
+    return false;
+  }
+
+  return crypto.timingSafeEqual(aBuffer, bBuffer);
+};

+ 185 - 47
polymarket/libs/parseMarkets.js

@@ -1,5 +1,8 @@
 import getDateInTimezone from "./getDateInTimezone.js";
 
+const MAKER_FEE_RATE = 0.03;
+const MAKER_REBATE_RATE = 0.25;
+
 /**
  * 精确浮点数字
  * @param {number} number
@@ -106,21 +109,61 @@ const ratioString = (ratio) => {
   return ratio;
 }
 
+/**
+ * 解析买单价格
+ * 当卖单最低价格与买单最高价格差值超过最小变动价位时,返回买单最高价格+最小变动价位
+ * 否则,返回买单最高价格
+ * @param {*} ask
+ * @param {*} bid
+ * @param {*} tickSize
+ * @returns {number}
+ */
+const parseBidPrice = (ask, bid, tickSize) => {
+  const askPrice = +ask;
+  const bidPrice = +bid;
+  const minTickSize = +tickSize;
+  const bestBidPrice = fixFloat(askPrice - minTickSize);
+  if (bestBidPrice > bidPrice) {
+    return bestBidPrice;
+  }
+  return bidPrice;
+}
+
+/**
+ * 解析吃单手续费比
+ * 固定金额吃卖单时,手续费比约等于 费率*(1-价格)
+ * @param {*} price
+ * @returns {number}
+ */
+const parseAskFee = (price) => {
+  return fixFloat(100 * MAKER_FEE_RATE * (1 - price), 4);
+}
+
+/**
+ * 解析挂单返佣比
+ * 固定金额挂买单时,返佣比约等于 费率*(1-价格)*返佣比例
+ * @param {*} price
+ * @returns {number}
+ */
+const parseBidRebate = (price) => {
+  return fixFloat(100 * MAKER_FEE_RATE * (1 - price) * MAKER_REBATE_RATE, 4);
+}
+
 /**
  * 解析盘口数据
  * 使用卖单最优价格
  * @param {*} markets
  * @returns {Object}
  */
-export const parseOdds = (markets) => {
+export const parseOddsAsk = (markets) => {
   const odds = {};
   Object.keys(markets).forEach(key => {
     const marketData = markets[key];
     if (key === 'moneyline') {
       Object.keys(marketData).forEach(side => {
-        const askYes = marketData[side].outcomes['Yes']['best_ask'];
+        const askYes = +marketData[side].outcomes['Yes']['best_ask'];
         const tokenYes = marketData[side].outcomes['Yes']['id'];
-        const askNo = marketData[side].outcomes['No']['best_ask'];
+        const askNo = +marketData[side].outcomes['No']['best_ask'];
         const tokenNo = marketData[side].outcomes['No']['id'];
         const slug = marketData[side].market.slug;
         if (askYes <= 0.1 || askNo <= 0.1) {
@@ -128,6 +171,8 @@ export const parseOdds = (markets) => {
         }
         const iorYes = fixFloat(1 / askYes);
         const iorNo = fixFloat(1 / askNo);
+        const feeYes = parseAskFee(askYes);
+        const feeNo = parseAskFee(askNo);
         let iorKeyYes = '';
         let iorKeyNo = '';
         switch (side) {
@@ -144,16 +189,16 @@ export const parseOdds = (markets) => {
             iorKeyNo = 'ior_moc';
             break;
         }
-        odds[iorKeyYes] = { v: iorYes, ask: askYes, token: tokenYes, slug };
-        odds[iorKeyNo] = { v: iorNo, ask: askNo, token: tokenNo, slug };
+        odds[iorKeyYes] = { v: iorYes, b: -feeYes, t: 1, ask: askYes, token: tokenYes, slug };
+        odds[iorKeyNo] = { v: iorNo, b: -feeNo, t: 1, ask: askNo, token: tokenNo, slug };
       });
     }
     else if (key === 'spreads') {
       Object.keys(marketData).forEach(handicap => {
         const ratio = +handicap;
-        const askHome = marketData[handicap].outcomes['Home']['best_ask'];
+        const askHome = +marketData[handicap].outcomes['Home']['best_ask'];
         const tokenHome = marketData[handicap].outcomes['Home']['id'];
-        const askAway = marketData[handicap].outcomes['Away']['best_ask'];
+        const askAway = +marketData[handicap].outcomes['Away']['best_ask'];
         const tokenAway = marketData[handicap].outcomes['Away']['id'];
         const slug = marketData[handicap].market.slug;
         if (askHome <= 0.1 || askAway <= 0.1) {
@@ -161,16 +206,18 @@ export const parseOdds = (markets) => {
         }
         const iorHome = fixFloat(1 / askHome);
         const iorAway = fixFloat(1 / askAway);
-        odds[`ior_r${ratioAccept(ratio)}h_${ratioString(ratio)}`] = { v: iorHome, ask: askHome, token: tokenHome, slug };
-        odds[`ior_r${ratioAccept(-ratio)}c_${ratioString(ratio)}`] = { v: iorAway, ask: askAway, token: tokenAway, slug };
+        const feeHome = parseAskFee(askHome);
+        const feeAway = parseAskFee(askAway);
+        odds[`ior_r${ratioAccept(ratio)}h_${ratioString(ratio)}`] = { v: iorHome, b: -feeHome, t: 1, ask: askHome, token: tokenHome, slug };
+        odds[`ior_r${ratioAccept(-ratio)}c_${ratioString(ratio)}`] = { v: iorAway, b: -feeAway, t: 1, ask: askAway, token: tokenAway, slug };
       });
     }
     else if (key === 'totals') {
       Object.keys(marketData).forEach(handicap => {
         const ratio = +handicap;
-        const askOver = marketData[handicap].outcomes['Over']['best_ask'];
+        const askOver = +marketData[handicap].outcomes['Over']['best_ask'];
         const tokenOver = marketData[handicap].outcomes['Over']['id'];
-        const askUnder = marketData[handicap].outcomes['Under']['best_ask'];
+        const askUnder = +marketData[handicap].outcomes['Under']['best_ask'];
         const tokenUnder = marketData[handicap].outcomes['Under']['id'];
         const slug = marketData[handicap].market.slug;
         if (askOver <= 0.1 || askUnder <= 0.1) {
@@ -178,8 +225,10 @@ export const parseOdds = (markets) => {
         }
         const iorOver = fixFloat(1 / askOver);
         const iorUnder = fixFloat(1 / askUnder);
-        odds[`ior_ouc_${ratioString(ratio)}`] = { v: iorOver, ask: askOver, token: tokenOver, slug };
-        odds[`ior_ouh_${ratioString(ratio)}`] = { v: iorUnder, ask: askUnder, token: tokenUnder, slug };
+        const feeOver = parseAskFee(askOver);
+        const feeUnder = parseAskFee(askUnder);
+        odds[`ior_ouc_${ratioString(ratio)}`] = { v: iorOver, b: -feeOver, t: 1, ask: askOver, token: tokenOver, slug };
+        odds[`ior_ouh_${ratioString(ratio)}`] = { v: iorUnder, b: -feeUnder, t: 1, ask: askUnder, token: tokenUnder, slug };
       });
     }
   });
@@ -200,19 +249,23 @@ export const parseOddsBid = (markets) => {
     const marketData = markets[key];
     if (key === 'moneyline') {
       Object.keys(marketData).forEach(side => {
-        const askYes = marketData[side].outcomes['Yes']['best_ask'];
-        const bidYes = marketData[side].outcomes['Yes']['best_bid'];
-        const tokenYes = marketData[side].outcomes['Yes']['id'];
-        const askNo = marketData[side].outcomes['No']['best_ask'];
-        const bidNo = marketData[side].outcomes['No']['best_bid'];
-        const tokenNo = marketData[side].outcomes['No']['id'];
-        const slug = marketData[side].market.slug;
+        const askYes = +marketData[side].outcomes['Yes']['best_ask'];
+        const bidYes = +marketData[side].outcomes['Yes']['best_bid'];
+        // const tokenYes = marketData[side].outcomes['Yes']['id'];
+        const askNo = +marketData[side].outcomes['No']['best_ask'];
+        const bidNo = +marketData[side].outcomes['No']['best_bid'];
+        // const tokenNo = marketData[side].outcomes['No']['id'];
+        // const slug = marketData[side].market.slug;
         const tick_size = marketData[side].market.orderPriceMinTickSize;
         if (askYes <= 0.1 || askNo <= 0.1) {
           return;
         }
-        const iorYes = fixFloat(1 / askYes);
-        const iorNo = fixFloat(1 / askNo);
+        const bidPriceYes = parseBidPrice(askYes, bidYes, tick_size);
+        const bidPriceNo = parseBidPrice(askNo, bidNo, tick_size);
+        const iorYes = fixFloat(1 / bidPriceYes);
+        const iorNo = fixFloat(1 / bidPriceNo);
+        const rebateYes = parseBidRebate(bidPriceYes);
+        const rebateNo = parseBidRebate(bidPriceNo);
         let iorKeyYes = '';
         let iorKeyNo = '';
         switch (side) {
@@ -229,48 +282,56 @@ export const parseOddsBid = (markets) => {
             iorKeyNo = 'ior_moc';
             break;
         }
-        odds[iorKeyYes] = { v: iorYes, ask: askYes, bid: bidYes, tick_size, token: tokenYes, slug };
-        odds[iorKeyNo] = { v: iorNo, ask: askNo, bid: bidNo, tick_size, token: tokenNo, slug };
+        odds[iorKeyYes] = { v: iorYes, b: rebateYes, t: 1, ask: askYes, bid: bidYes, bid_ex: bidPriceYes, tick_size, /*token: tokenYes, slug */ };
+        odds[iorKeyNo] = { v: iorNo, b: rebateNo, t: 1, ask: askNo, bid: bidNo, bid_ex: bidPriceNo, tick_size, /*token: tokenNo, slug */ };
       });
     }
     else if (key === 'spreads') {
       Object.keys(marketData).forEach(handicap => {
         const ratio = +handicap;
-        const askHome = marketData[handicap].outcomes['Home']['best_ask'];
-        const bidHome = marketData[handicap].outcomes['Home']['best_bid'];
-        const tokenHome = marketData[handicap].outcomes['Home']['id'];
-        const askAway = marketData[handicap].outcomes['Away']['best_ask'];
-        const bidAway = marketData[handicap].outcomes['Away']['best_bid'];
-        const tokenAway = marketData[handicap].outcomes['Away']['id'];
-        const slug = marketData[handicap].market.slug;
+        const askHome = +marketData[handicap].outcomes['Home']['best_ask'];
+        const bidHome = +marketData[handicap].outcomes['Home']['best_bid'];
+        // const tokenHome = marketData[handicap].outcomes['Home']['id'];
+        const askAway = +marketData[handicap].outcomes['Away']['best_ask'];
+        const bidAway = +marketData[handicap].outcomes['Away']['best_bid'];
+        // const tokenAway = marketData[handicap].outcomes['Away']['id'];
+        // const slug = marketData[handicap].market.slug;
         const tick_size = marketData[handicap].market.orderPriceMinTickSize;
         if (askHome <= 0.1 || askAway <= 0.1) {
           return;
         }
-        const iorHome = fixFloat(1 / askHome);
-        const iorAway = fixFloat(1 / askAway);
-        odds[`ior_r${ratioAccept(ratio)}h_${ratioString(ratio)}`] = { v: iorHome, ask: askHome, bid: bidHome, tick_size, token: tokenHome, slug };
-        odds[`ior_r${ratioAccept(-ratio)}c_${ratioString(ratio)}`] = { v: iorAway, ask: askAway, bid: bidAway, tick_size, token: tokenAway, slug };
+        const bidPriceHome = parseBidPrice(askHome, bidHome, tick_size);
+        const bidPriceAway = parseBidPrice(askAway, bidAway, tick_size);
+        const iorHome = fixFloat(1 / bidPriceHome);
+        const iorAway = fixFloat(1 / bidPriceAway);
+        const rebateHome = parseBidRebate(bidPriceHome);
+        const rebateAway = parseBidRebate(bidPriceAway);
+        odds[`ior_r${ratioAccept(ratio)}h_${ratioString(ratio)}`] = { v: iorHome, b: rebateHome, t: 1, ask: askHome, bid: bidHome, bid_ex: bidPriceHome, tick_size, /*token: tokenHome, slug */ };
+        odds[`ior_r${ratioAccept(-ratio)}c_${ratioString(ratio)}`] = { v: iorAway, b: rebateAway, t: 1, ask: askAway, bid: bidAway, bid_ex: bidPriceAway, tick_size, /*token: tokenAway, slug */ };
       });
     }
     else if (key === 'totals') {
       Object.keys(marketData).forEach(handicap => {
         const ratio = +handicap;
-        const askOver = marketData[handicap].outcomes['Over']['best_ask'];
-        const bidOver = marketData[handicap].outcomes['Over']['best_bid'];
-        const tokenOver = marketData[handicap].outcomes['Over']['id'];
-        const askUnder = marketData[handicap].outcomes['Under']['best_ask'];
-        const bidUnder = marketData[handicap].outcomes['Under']['best_bid'];
-        const tokenUnder = marketData[handicap].outcomes['Under']['id'];
-        const slug = marketData[handicap].market.slug;
+        const askOver = +marketData[handicap].outcomes['Over']['best_ask'];
+        const bidOver = +marketData[handicap].outcomes['Over']['best_bid'];
+        // const tokenOver = marketData[handicap].outcomes['Over']['id'];
+        const askUnder = +marketData[handicap].outcomes['Under']['best_ask'];
+        const bidUnder = +marketData[handicap].outcomes['Under']['best_bid'];
+        // const tokenUnder = marketData[handicap].outcomes['Under']['id'];
+        // const slug = marketData[handicap].market.slug;
         const tick_size = marketData[handicap].market.orderPriceMinTickSize;
         if (askOver <= 0.1 || askUnder <= 0.1) {
           return;
         }
-        const iorOver = fixFloat(1 / askOver);
-        const iorUnder = fixFloat(1 / askUnder);
-        odds[`ior_ouc_${ratioString(ratio)}`] = { v: iorOver, ask: askOver, bid: bidOver, tick_size, token: tokenOver, slug };
-        odds[`ior_ouh_${ratioString(ratio)}`] = { v: iorUnder, ask: askUnder, bid: bidUnder, tick_size, token: tokenUnder, slug };
+        const bidPriceOver = parseBidPrice(askOver, bidOver, tick_size);
+        const bidPriceUnder = parseBidPrice(askUnder, bidUnder, tick_size);
+        const iorOver = fixFloat(1 / bidPriceOver);
+        const iorUnder = fixFloat(1 / bidPriceUnder);
+        const rebateOver = parseBidRebate(bidPriceOver);
+        const rebateUnder = parseBidRebate(bidPriceUnder);
+        odds[`ior_ouc_${ratioString(ratio)}`] = { v: iorOver, b: rebateOver, t: 1, ask: askOver, bid: bidOver, bid_ex: bidPriceOver, tick_size, /*token: tokenOver, slug */ };
+        odds[`ior_ouh_${ratioString(ratio)}`] = { v: iorUnder, b: rebateUnder, t: 1, ask: askUnder, bid: bidUnder, bid_ex: bidPriceUnder, tick_size, /*token: tokenUnder, slug */ };
       });
     }
   });
@@ -425,4 +486,81 @@ export const parseMarkets = (eventsData) => {
   return mergedMarketsData;
 }
 
-// export default parseMarkets;
+/*
+* 解析比率信息
+*/
+const parseRatio = (ratioString) => {
+  if (!ratioString) {
+    return null;
+  }
+  return parseFloat(`${ratioString[0]}.${ratioString.slice(1)}`);
+ }
+
+ /**
+  * 解析盘口信息
+  * @param {*} ior
+  * @returns
+  */
+ const parseIor = (ior) => {
+  const iorMatch = ior.match(/ior_(m|r|ou|wm|ot)([ao])?([hcn])?_?(\d+)?/);
+  if (!iorMatch) {
+    return null;
+  }
+  const [, type, action, side, ratio] = iorMatch;
+  return { type, action, side, ratio };
+ }
+
+ /**
+ * 解析盘口详情
+ * @param {*} ior
+ * @param {*} id
+ * @param {*} marketsMap
+ * @returns
+ */
+ export const parseIorDetail = (ior, id, marketsMap) => {
+  const marketsData = marketsMap[id]?.marketsData;
+  if (!marketsData) {
+    return { ior, id, message: 'markets data not found', cause: 400 };
+  }
+  const iorOptions = parseIor(ior);
+  if (!iorOptions) {
+    return { ior, id, message: 'ior options not found', cause: 400 };
+  }
+  const { type, action, side, ratio } = iorOptions;
+
+  let marketTypeData, outcomesSide;
+
+  if (type === 'm' && !ratio) {
+    const sideKey = side === 'h' ? 'Home' : side === 'c' ? 'Away' : 'Draw';
+    const sideAction = action === 'o' ? 'No' : 'Yes';
+    marketTypeData = marketsData.moneyline[sideKey];
+    outcomesSide = sideAction;
+  }
+
+  else if (type === 'r') {
+    const sideKey = side === 'h' ? 'Home' : side === 'c' ? 'Away' : '';
+    let ratioDirection = 1;
+    if (side === 'c' && action === 'a' || side === 'h' && !action) {
+      ratioDirection = -1;
+    }
+    const ratioValue = parseRatio(ratio) * ratioDirection;
+    const ratioKey = ratioValue > 0 ? `+${ratioValue}` : `${ratioValue}`;
+    marketTypeData = marketsData.spreads?.[ratioKey];
+    outcomesSide = sideKey;
+  }
+
+  else if (type === 'ou') {
+    const sideKey = side === 'c' ? 'Over' : side === 'h' ? 'Under' : '';
+    const ratioKey = parseRatio(ratio);
+    marketTypeData = marketsData.totals[ratioKey];
+    outcomesSide = sideKey;
+  }
+
+  const result = marketTypeData?.outcomes?.[outcomesSide];
+
+  if (!result) {
+    Logs.outDev('polymarket market type data not found', { ior, id, type, action, side, ratio, marketTypeData, outcomesSide });
+    return { ior, id, message: 'market type data not found', cause: 400 };
+  }
+  return result;
+ }

+ 186 - 0
polymarket/libs/pinnacleClient.js

@@ -0,0 +1,186 @@
+import axios from "axios";
+import { HttpsProxyAgent } from "https-proxy-agent";
+
+import { randomUUID } from 'crypto';
+
+import Logs from "./logs.js";
+
+const axiosDefaultOptions = {
+  baseURL: "",
+  url: "",
+  method: "GET",
+  headers: {},
+  params: {},
+  data: {},
+  timeout: 10000,
+};
+
+export const pinnacleRequest = async (options, channel) => {
+
+  const { url, ...optionsRest } = options;
+  const username = process.env.PINNACLE_USERNAME;
+  const password = process.env.PINNACLE_PASSWORD;
+  if (!url || !channel && (!username || !password)) {
+    throw new Error("url、username、password、channel is required");
+  }
+
+  const authHeader = channel ? `Basic ${channel}` : `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
+  const axiosConfig = { ...axiosDefaultOptions, ...optionsRest, url, baseURL: "https://api.pinnacle888.com" };
+  Object.assign(axiosConfig.headers, {
+    "Authorization": authHeader,
+    "Accept": "application/json",
+  });
+  const proxy = process.env.NODE_HTTP_PROXY;
+  if (proxy) {
+    axiosConfig.proxy = false;
+    axiosConfig.httpsAgent = new HttpsProxyAgent(proxy);
+  }
+  Logs.outDev('pinnacle request', url, axiosConfig, { channel, username, password });
+  return axios(axiosConfig).then(res => {
+    return res.data;
+  });
+}
+
+/**
+ * Pinnacle API Get请求
+ * @param {*} url
+ * @param {*} params
+ * @returns
+ */
+export const pinnacleGet = async (url, params, channel) => {
+  return pinnacleRequest({
+    url,
+    params
+  }, channel)
+  .catch(err => {
+    Logs.errDev('pinnacle get error', err);
+    if (err.response?.data) {
+      err.data = err.response.data;
+      err.cause = err.response.status;
+    }
+    return Promise.reject(err);
+  });
+}
+
+/**
+ * Pinnacle API Post请求
+ * @param {*} url
+ * @param {*} data
+ * @returns
+ */
+export const pinnaclePost = async (url, data, channel) => {
+  return pinnacleRequest({
+    url,
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    data
+  }, channel);
+}
+
+/**
+ * 清理对象中的undefined值
+ * @param {*} obj
+ * @returns {Object}
+ */
+const cleanUndefined = (obj) => {
+  return Object.fromEntries(
+    Object.entries(obj).filter(([, v]) => v !== undefined)
+  );
+}
+
+/**
+ * 获取直盘线
+ */
+export const getLineInfo = async ({ info = {}, channel }) => {
+  const {
+    leagueId, eventId, betType, handicap, team, side,
+    specialId, contestantId,
+    periodNumber=0, oddsFormat='Decimal', sportId=29
+  } = info;
+  let url = '/v2/line/';
+  let data = { sportId, leagueId, eventId, betType, handicap, periodNumber, team, side, oddsFormat };
+  if (specialId) {
+    url = '/v2/line/special';
+    data = { specialId, contestantId, oddsFormat };
+  }
+  data = cleanUndefined(data);
+  return pinnacleGet(url, data, channel)
+  .then(ret => ({ info: ret, line: data }))
+  .catch(err => {
+    Logs.outDev('get line info error', err.data);
+    Logs.errDev(err);
+    return Promise.reject(err);
+  });
+}
+
+/**
+ * 获取账户余额
+ */
+export const getAccountBalance = async () => {
+  return pinnacleGet('/v1/client/balance');
+}
+
+/**
+ * 下注
+ */
+export const placeOrder = async ({ info, line, stakeSize }, channel) => {
+  // return Promise.resolve({info, line, stakeSize});
+  const uuid = randomUUID()
+  if (line.specialId) {
+    const data = cleanUndefined({
+      oddsFormat: line.oddsFormat,
+      uniqueRequestId: uuid,
+      acceptBetterLine: true,
+      stake: stakeSize,
+      winRiskStake: 'RISK',
+      lineId: info.lineId,
+      specialId: info.specialId,
+      contestantId: info.contestantId,
+    });
+    Logs.outDev('pinnacle place order data', data);
+    return pinnaclePost('/v4/bets/special', { bets: [data] }, channel)
+    .then(ret => ret.bets?.[0] ?? ret)
+    .then(ret => {
+      Logs.outDev('pinnacle place order', ret, uuid);
+      return ret;
+    })
+    .catch(err => {
+      Logs.outDev('pinnacle place order error', err.data, uuid);
+      Logs.errDev(err);
+      return Promise.reject(err);
+    });
+  }
+  else {
+    const data = cleanUndefined({
+      oddsFormat: line.oddsFormat,
+      uniqueRequestId: uuid,
+      acceptBetterLine: true,
+      stake: stakeSize,
+      winRiskStake: 'RISK',
+      lineId: info.lineId,
+      altLineId: info.altLineId,
+      fillType: 'NORMAL',
+      sportId: line.sportId,
+      eventId: line.eventId,
+      periodNumber: line.periodNumber,
+      betType: line.betType,
+      team: line.team,
+      side: line.side,
+      handicap: line.handicap,
+    });
+    Logs.outDev('pinnacle place order data', data);
+    return pinnaclePost('/v4/bets/place', data, channel)
+    .then(ret => {
+      Logs.outDev('pinnacle place order', ret, uuid);
+      return ret;
+    })
+    .catch(err => {
+      Logs.outDev('pinnacle place order error', err.data, uuid);
+      Logs.errDev(err);
+      return Promise.reject(err);
+    });
+  }
+}
+

+ 563 - 52
polymarket/libs/polymarketClient.js

@@ -1,13 +1,40 @@
 import axios from "axios";
 import qs from "qs";
 import { HttpsProxyAgent } from "https-proxy-agent";
-import { ClobClient, Side, OrderType } from "@polymarket/clob-client";
-import { Wallet } from "ethers";
+import { AssetType, Chain, ClobClient, OrderType, Side, SignatureTypeV2 } from "@polymarket/clob-client-v2";
+import { BuilderConfig } from "@polymarket/builder-signing-sdk";
+import { deriveProxyWallet, RelayerTxType, RelayClient } from "@polymarket/builder-relayer-client";
+import {
+  createPublicClient,
+  createWalletClient,
+  encodeFunctionData,
+  erc20Abi,
+  formatUnits,
+  http,
+  parseUnits,
+} from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+import { polygon } from "viem/chains";
 
 import WebSocketClient from "./webSocketClient.js";
 
 import Logs from "./logs.js";
 
+const NODE_HTTP_PROXY = process.env.NODE_HTTP_PROXY;
+const proxyAgent = NODE_HTTP_PROXY ? new HttpsProxyAgent(NODE_HTTP_PROXY) : undefined;
+if (NODE_HTTP_PROXY) {
+  axios.defaults.proxy = false;
+  axios.defaults.httpAgent = proxyAgent;
+  axios.defaults.httpsAgent = proxyAgent;
+}
+
+const CHAIN_ID = 137;
+const GAMMA_HOST = "https://gamma-api.polymarket.com";
+const CLOB_HOST = "https://clob.polymarket.com";
+const PUSD_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB";
+const PUSD_DECIMALS = 6;
+const PROXY_FACTORY_ADDRESS = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052";
+
 /**
  * axios 默认配置
  */
@@ -38,11 +65,6 @@ const clientRequest = async (options, baseURL) => {
     baseURL,
     paramsSerializer: params => qs.stringify(params, { arrayFormat: 'repeat' })
   };
-  const proxy = process.env.NODE_HTTP_PROXY;
-  if (proxy) {
-    mergedOptions.proxy = false;
-    mergedOptions.httpsAgent = new HttpsProxyAgent(proxy);
-  }
   return axios(mergedOptions).then(res => res.data);
 }
 
@@ -52,14 +74,441 @@ const clientRequest = async (options, baseURL) => {
  * @returns
  */
 const requestMarketData = async (options) => {
-  return clientRequest(options, "https://gamma-api.polymarket.com");
+  return clientRequest(options, GAMMA_HOST);
 }
 
 /**
  * 请求订单簿数据
  */
 const requestClobData = async (options) => {
-  return clientRequest(options, "https://clob.polymarket.com");
+  return clientRequest(options, CLOB_HOST);
+}
+
+const getRequiredEnv = (key) => {
+  const value = process.env[key];
+  if (!value) {
+    throw new Error(`${key} is required`);
+  }
+  return value;
+}
+
+const normalizePrivateKey = (privateKey) => {
+  return privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
+}
+
+const getOptionalEnv = (key) => {
+  const value = process.env[key];
+  return value && value.trim() ? value.trim() : undefined;
+}
+
+const getPolymarketFunderAddress = (accountAddress) => {
+  return getOptionalEnv("POLYMARKET_DEPOSIT_WALLET_ADDRESS")
+    || getOptionalEnv("POLYMARKET_FUNDER_ADDRESS")
+    || accountAddress;
+}
+
+const getPolymarketSignatureType = (funderAddress, accountAddress) => {
+  const configuredType = getOptionalEnv("POLYMARKET_SIGNATURE_TYPE");
+  if (configuredType) {
+    const signatureType = SignatureTypeV2[configuredType] ?? Number(configuredType);
+    if (!Number.isInteger(signatureType) || !SignatureTypeV2[signatureType]) {
+      throw new Error(`POLYMARKET_SIGNATURE_TYPE is invalid: ${configuredType}`);
+    }
+    return signatureType;
+  }
+
+  return funderAddress.toLowerCase() === accountAddress.toLowerCase()
+    ? SignatureTypeV2.POLY_PROXY
+    : SignatureTypeV2.POLY_1271;
+}
+
+/**
+ * 创建 viem HTTP transport
+ * NODE_HTTP_PROXY 存在时通过 axios 代理请求 Polygon RPC
+ * @param {string} rpcUrl
+ * @returns {import("viem").HttpTransport}
+ */
+const createViemHttpTransport = (rpcUrl) => {
+  if (!NODE_HTTP_PROXY) {
+    return rpcUrl ? http(rpcUrl) : http();
+  }
+
+  const fetchFn = async (url, init = {}) => {
+    const headers = Object.fromEntries(new Headers(init.headers ?? {}).entries());
+    const response = await axios({
+      url,
+      method: init.method || "POST",
+      headers,
+      data: init.body,
+      transformResponse: [data => data],
+      responseType: "text",
+      validateStatus: () => true,
+    });
+
+    return new Response(response.data, {
+      status: response.status,
+      statusText: response.statusText,
+      headers: response.headers,
+    });
+  };
+
+  return http(rpcUrl, { fetchFn });
+}
+
+export const createClobClient = () => {
+  const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY")));
+  const signer = createWalletClient({
+    account,
+    chain: polygon,
+    transport: createViemHttpTransport(getOptionalEnv("POLYGON_RPC_URL")),
+  });
+  const funderAddress = getPolymarketFunderAddress(account.address);
+  const userApiCreds = {
+    key: getRequiredEnv("POLYMARKET_API_KEY"),
+    secret: getRequiredEnv("POLYMARKET_API_SECRET"),
+    passphrase: getRequiredEnv("POLYMARKET_API_PASSPHRASE"),
+  };
+
+  return new ClobClient({
+    host: CLOB_HOST,
+    chain: Chain.POLYGON,
+    signer,
+    creds: userApiCreds,
+    signatureType: getPolymarketSignatureType(funderAddress, account.address),
+    funderAddress,
+    throwOnError: true,
+  });
+}
+
+const createPolymarketContext = () => {
+  const rpcUrl = getOptionalEnv("POLYGON_RPC_URL");
+  const transport = createViemHttpTransport(rpcUrl);
+  const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY")));
+  const signer = createWalletClient({ account, chain: polygon, transport });
+  const publicClient = createPublicClient({ chain: polygon, transport });
+  const creds = {
+    key: getRequiredEnv("POLYMARKET_API_KEY"),
+    secret: getRequiredEnv("POLYMARKET_API_SECRET"),
+    passphrase: getRequiredEnv("POLYMARKET_API_PASSPHRASE"),
+  };
+
+  return { account, signer, publicClient, creds };
+}
+
+const normalizeWalletMode = (wallet = "both") => {
+  const value = wallet.toLowerCase();
+  if (!["deposit", "proxy", "both"].includes(value)) {
+    throw new Error("wallet must be deposit, proxy, or both", { cause: 400 });
+  }
+  return value;
+}
+
+const createBalanceClobClient = ({ signer, creds, funderAddress, signatureType }) => {
+  return new ClobClient({
+    host: CLOB_HOST,
+    chain: Chain.POLYGON,
+    signer,
+    creds,
+    signatureType,
+    funderAddress,
+    throwOnError: true,
+  });
+}
+
+const getChainPusdBalance = async ({ publicClient, address }) => {
+  const balance = await publicClient.readContract({
+    address: PUSD_ADDRESS,
+    abi: erc20Abi,
+    functionName: "balanceOf",
+    args: [address],
+  });
+  return formatUnits(balance, PUSD_DECIMALS);
+}
+
+const getClobBalanceAllowance = async ({ signer, creds, funderAddress, signatureType }) => {
+  const client = createBalanceClobClient({ signer, creds, funderAddress, signatureType });
+  return client.getBalanceAllowance({ asset_type: AssetType.COLLATERAL });
+}
+
+const createBuilderConfig = () => {
+  return new BuilderConfig({
+    localBuilderCreds: {
+      key: getRequiredEnv("POLYMARKET_BUILDER_API_KEY"),
+      secret: getRequiredEnv("POLYMARKET_BUILDER_SECRET"),
+      passphrase: getRequiredEnv("POLYMARKET_BUILDER_PASS_PHRASE"),
+    },
+  });
+}
+
+/**
+ * 创建 Polymarket builder relayer 客户端
+ * @param {Object} options
+ * @param {Object} options.signer viem wallet client
+ * @param {number} options.relayTxType relayer 交易类型
+ * @returns {RelayClient}
+ */
+const createRelayer = ({ signer, relayTxType = RelayerTxType.PROXY } = {}) => {
+  const relayerUrl = getOptionalEnv("POLYMARKET_RELAYER_URL") || "https://relayer-v2.polymarket.com";
+  const relayer = new RelayClient(relayerUrl, CHAIN_ID, signer, createBuilderConfig(), relayTxType);
+  if (proxyAgent) {
+    relayer.httpClient.instance.defaults.proxy = false;
+    relayer.httpClient.instance.defaults.httpAgent = proxyAgent;
+    relayer.httpClient.instance.defaults.httpsAgent = proxyAgent;
+  }
+  return relayer;
+}
+
+/**
+ * 获取 Polymarket proxy wallet 地址
+ * @param {Object} options
+ * @param {string} options.ownerAddress owner 钱包地址
+ * @returns {string}
+ */
+const getProxyWalletAddress = ({ ownerAddress }) => {
+  return getOptionalEnv("POLYMARKET_PROXY_WALLET_ADDRESS")
+    || deriveProxyWallet(ownerAddress, PROXY_FACTORY_ADDRESS);
+}
+
+/**
+ * 获取 Polymarket deposit wallet 地址
+ * @param {Object} options
+ * @param {Object} options.signer viem wallet client
+ * @returns {Promise<string>}
+ */
+const getDepositWalletAddress = async ({ signer }) => {
+  const configuredAddress = getOptionalEnv("POLYMARKET_DEPOSIT_WALLET_ADDRESS");
+  if (configuredAddress) {
+    return configuredAddress;
+  }
+
+  const relayer = createRelayer({ signer });
+  return relayer.deriveDepositWalletAddress();
+}
+
+/**
+ * 获取 pUSD 转账所需的钱包上下文
+ * @returns {Promise<Object>} owner、signer、publicClient、relayer 和 proxy/deposit 钱包地址
+ */
+const getTransferWalletContext = async () => {
+  const { account, signer, publicClient } = createPolymarketContext();
+  const proxyRelayer = createRelayer({ signer, relayTxType: RelayerTxType.PROXY });
+  const proxyWalletAddress = getProxyWalletAddress({ ownerAddress: account.address });
+  const depositWalletAddress = await getDepositWalletAddress({ signer });
+
+  return {
+    account,
+    signer,
+    publicClient,
+    proxyRelayer,
+    proxyWalletAddress,
+    depositWalletAddress,
+  };
+}
+
+/**
+ * 获取钱包原始 pUSD 余额
+ * @param {Object} options
+ * @param {Object} options.publicClient viem public client
+ * @param {string} options.address 钱包地址
+ * @returns {Promise<bigint>}
+ */
+const getRawPusdBalance = ({ publicClient, address }) => {
+  return publicClient.readContract({
+    address: PUSD_ADDRESS,
+    abi: erc20Abi,
+    functionName: "balanceOf",
+    args: [address],
+  });
+}
+
+/**
+ * 校验并转换转账金额为 pUSD 最小单位
+ * @param {string|number} amount 转账数量
+ * @returns {bigint}
+ */
+const normalizeTransferAmount = (amount) => {
+  if (!amount || !Number.isFinite(Number(amount)) || Number(amount) <= 0) {
+    throw new Error("amount must be greater than 0", { cause: 400 });
+  }
+  return parseUnits(String(amount), PUSD_DECIMALS);
+}
+
+/**
+ * 校验并标准化钱包转账方向
+ * @param {Object} options
+ * @param {string} options.from 来源钱包类型
+ * @param {string} options.to 目标钱包类型
+ * @returns {{from: "proxy"|"deposit", to: "proxy"|"deposit"}}
+ */
+const normalizeTransferDirection = ({ from, to } = {}) => {
+  const normalizedFrom = String(from || "").toLowerCase();
+  const normalizedTo = String(to || "").toLowerCase();
+  if (
+    !["proxy", "deposit"].includes(normalizedFrom)
+    || !["proxy", "deposit"].includes(normalizedTo)
+    || normalizedFrom === normalizedTo
+  ) {
+    throw new Error("Transfer direction must be proxy -> deposit or deposit -> proxy", { cause: 400 });
+  }
+  return { from: normalizedFrom, to: normalizedTo };
+}
+
+/**
+ * 生成 pUSD ERC20 transfer 调用数据
+ * @param {Object} options
+ * @param {string} options.to 收款钱包地址
+ * @param {bigint} options.amount pUSD 最小单位金额
+ * @returns {string}
+ */
+const createPusdTransferData = ({ to, amount }) => {
+  return encodeFunctionData({
+    abi: erc20Abi,
+    functionName: "transfer",
+    args: [to, amount],
+  });
+}
+
+/**
+ * 统一格式化钱包转账返回结果
+ * @param {Object} options
+ * @returns {Object}
+ */
+const buildTransferResult = ({
+  owner,
+  from,
+  to,
+  sourceAddress,
+  destinationAddress,
+  amount,
+  sourceBalance,
+  transactionID,
+  transactionHash,
+  confirmed,
+}) => {
+  return {
+    owner,
+    pUSD: PUSD_ADDRESS,
+    from,
+    to,
+    sourceAddress,
+    destinationAddress,
+    amount: String(amount),
+    sourceBalance: formatUnits(sourceBalance, PUSD_DECIMALS),
+    transactionID,
+    transactionHash,
+    confirmed,
+  };
+}
+
+/**
+ * 校验来源钱包 pUSD 余额是否足够
+ * @param {Object} options
+ * @param {string} options.wallet 钱包类型
+ * @param {bigint} options.balance 当前余额
+ * @param {bigint} options.amount 转账金额
+ */
+const ensureSufficientPusdBalance = ({ wallet, balance, amount }) => {
+  if (balance < amount) {
+    throw new Error(`Insufficient ${wallet} wallet pUSD balance: ${formatUnits(balance, PUSD_DECIMALS)}`, { cause: 400 });
+  }
+}
+
+/**
+ * 根据转账方向提交 relayer 交易
+ * @param {Object} options
+ * @param {"proxy"|"deposit"} options.from 来源钱包类型
+ * @param {"proxy"|"deposit"} options.to 目标钱包类型
+ * @param {Object} options.context 转账钱包上下文
+ * @param {string} options.data pUSD transfer 调用数据
+ * @param {string|number} options.amount 展示用转账数量
+ * @returns {Promise<Object>}
+ */
+const executePusdTransfer = ({
+  from,
+  to,
+  context,
+  data,
+  amount,
+}) => {
+  if (from === "proxy" && to === "deposit") {
+    return context.proxyRelayer.execute(
+      [{
+        to: PUSD_ADDRESS,
+        data,
+        value: "0",
+      }],
+      `transfer ${amount} pUSD from proxy to deposit wallet`,
+    );
+  }
+
+  const depositRelayer = createRelayer({ signer: context.signer });
+  return depositRelayer.executeDepositWalletBatch(
+    [{
+      target: PUSD_ADDRESS,
+      data,
+      value: "0",
+    }],
+    context.depositWalletAddress,
+    String(Math.floor(Date.now() / 1000) + 600),
+  );
+}
+
+/**
+ * 执行 pUSD 钱包间转账的公共流程
+ * @param {Object} options
+ * @param {string|number} options.amount 转账数量
+ * @param {"proxy"|"deposit"} options.from 来源钱包类型
+ * @param {"proxy"|"deposit"} options.to 目标钱包类型
+ * @returns {Promise<Object>}
+ */
+const transferPusdBetweenWallets = async ({
+  amount,
+  from,
+  to,
+} = {}) => {
+  const transferAmount = normalizeTransferAmount(amount);
+  const context = await getTransferWalletContext();
+  const sourceAddress = from === "proxy"
+    ? context.proxyWalletAddress
+    : context.depositWalletAddress;
+  const destinationAddress = to === "proxy"
+    ? context.proxyWalletAddress
+    : context.depositWalletAddress;
+  const sourceBalance = await getRawPusdBalance({
+    publicClient: context.publicClient,
+    address: sourceAddress,
+  });
+  ensureSufficientPusdBalance({
+    wallet: from,
+    balance: sourceBalance,
+    amount: transferAmount,
+  });
+
+  const data = createPusdTransferData({
+    to: destinationAddress,
+    amount: transferAmount,
+  });
+  const response = await executePusdTransfer({
+    from,
+    to,
+    context,
+    data,
+    amount,
+  });
+  const confirmed = await response.wait();
+
+  return buildTransferResult({
+    owner: context.account.address,
+    from,
+    to,
+    sourceAddress,
+    destinationAddress,
+    amount,
+    sourceBalance,
+    transactionID: response.transactionID,
+    transactionHash: response.transactionHash || response.hash,
+    confirmed,
+  });
 }
 
 /**
@@ -131,60 +580,113 @@ export const getMultipleOrderBooks = async (tokenIds) => {
 }
 
 /**
- * 下注
+ * 获取 USDC collateral 余额和授权信息
  */
-export const placeOrder = async (info) => {
-  // return Promise.resolve(info);
-  const { asset_id, bestPrice, stakeSize, tick_size: tickSize, neg_risk: negRisk } = info;
+export const getBalanceAllowance = async ({ wallet = "both" } = {}) => {
+  const walletMode = normalizeWalletMode(wallet);
+  const { account, signer, publicClient, creds } = createPolymarketContext();
+  const wallets = [];
 
-  const HOST = "https://clob.polymarket.com";
-  const CHAIN_ID = 137; // Polygon mainnet
-  const signer = new Wallet(process.env.POLYMARKET_PRIVATE_KEY);
-  const userApiCreds = {
-    key: process.env.POLYMARKET_API_KEY,
-    secret: process.env.POLYMARKET_API_SECRET,
-    passphrase: process.env.POLYMARKET_API_PASSPHRASE,
-  };
+  if (walletMode === "proxy" || walletMode === "both") {
+    wallets.push({
+      type: "proxy",
+      address: getProxyWalletAddress({ ownerAddress: account.address }),
+      signatureType: SignatureTypeV2.POLY_PROXY,
+    });
+  }
 
-  const SIGNATURE_TYPE = 0;
-  const FUNDER_ADDRESS = signer.address;
+  if (walletMode === "deposit" || walletMode === "both") {
+    wallets.push({
+      type: "deposit",
+      address: await getDepositWalletAddress({ signer }),
+      signatureType: SignatureTypeV2.POLY_1271,
+    });
+  }
 
-  // Logs.outDev('clob client init', HOST, CHAIN_ID, signer.address, userApiCreds, SIGNATURE_TYPE, FUNDER_ADDRESS);
+  const results = [];
+  for (const item of wallets) {
+    const [chainPusdBalance, clobBalanceAllowance] = await Promise.all([
+      getChainPusdBalance({ publicClient, address: item.address }),
+      getClobBalanceAllowance({
+        signer,
+        creds,
+        funderAddress: item.address,
+        signatureType: item.signatureType,
+      }),
+    ]);
+    results.push({
+      type: item.type,
+      owner: account.address,
+      address: item.address,
+      signatureType: SignatureTypeV2[item.signatureType],
+      chainPusdBalance,
+      clobBalanceAllowance,
+    });
+  }
 
-  const client = new ClobClient(
-    HOST,
-    CHAIN_ID,
-    signer,
-    userApiCreds,
-    SIGNATURE_TYPE,
-    FUNDER_ADDRESS
-  );
+  return results;
+}
 
-  const orderData = {
-    tokenID: asset_id,
-    price: bestPrice,
-    size: stakeSize,
-    side: Side.BUY,
-  }
+/**
+ * 在 Proxy wallet 和 Deposit wallet 之间转 pUSD
+ * @param {Object} options
+ * @param {string|number} options.amount 转账数量
+ * @param {"proxy"|"deposit"} options.from 来源钱包类型
+ * @param {"proxy"|"deposit"} options.to 目标钱包类型
+ * @returns {Promise<Object>}
+ */
+export const transferWallet = async ({ amount, from, to } = {}) => {
+  const direction = normalizeTransferDirection({ from, to });
+  return transferPusdBetweenWallets({
+    amount,
+    from: direction.from,
+    to: direction.to,
+  });
+}
 
-  const orderOptions = { tickSize, negRisk }
+/**
+ * 创建 Polymarket 限价挂单
+ */
+export const createLimitOrder = async ({
+  tokenID,
+  price,
+  size,
+  side = Side.BUY,
+  tickSize = "0.01",
+  negRisk = false,
+  orderType = OrderType.GTC,
+  postOnly = true,
+  expiration,
+  deferExec = false,
+} = {}) => {
+  if (!tokenID) {
+    throw new Error("tokenID is required", { cause: 400 });
+  }
+  if (!Number.isFinite(Number(price))) {
+    throw new Error("price is required", { cause: 400 });
+  }
+  if (!Number.isFinite(Number(size))) {
+    throw new Error("size is required", { cause: 400 });
+  }
+  if (orderType !== OrderType.GTC && orderType !== OrderType.GTD) {
+    throw new Error(`orderType must be ${OrderType.GTC} or ${OrderType.GTD}`, { cause: 400 });
+  }
 
-  Logs.outDev('polymarket place order data', orderData, orderOptions, OrderType.FOK);
+  const client = createClobClient();
+  const orderData = {
+    tokenID,
+    price: Number(price),
+    size: Number(size),
+    side,
+    ...(expiration ? { expiration: Number(expiration) } : {}),
+  };
+  const orderOptions = { tickSize, negRisk };
 
-  // return client.getBalanceAllowance({ asset_type: 'COLLATERAL' })
+  Logs.outDev('polymarket create limit order data', orderData, orderOptions, orderType, postOnly, deferExec);
 
-  return client.createAndPostOrder(orderData, orderOptions, OrderType.FOK)
-  .then(res => {
-    // if (res.error) {
-    //   return Promise.reject(new Error(res.error));
-    // }
-    return res;
-  });
-  // return Promise.resolve(info);
-  // return Promise.reject(new Error('polymarket place order not implemented', { cause: 400 }));
+  return client.createAndPostOrder(orderData, orderOptions, orderType, postOnly, deferExec);
 }
 
-
 /**
  * 请求平台数据
  * @param {*} options
@@ -195,10 +697,19 @@ export const platformRequest = async (options) => {
   if (!url) {
     throw new Error("url is required");
   }
+  const internalToken = process.env.PPAI_INTERNAL_API_TOKEN;
   const mergedOptions = {
     ...axiosDefaultOptions,
     ...options,
     baseURL: "http://127.0.0.1:9020",
+    headers: {
+      ...axiosDefaultOptions.headers,
+      ...options.headers,
+      ...(internalToken ? { Authorization: `Bearer ${internalToken}` } : {}),
+    },
+    httpAgent: null,
+    httpsAgent: null,
+    proxy: false,
   };
   return axios(mergedOptions).then(res => res.data);
 }
@@ -269,4 +780,4 @@ export class MarketWsClient extends WebSocketClient {
       assets_ids: assetIds,
     });
   }
-}
+}

+ 386 - 0
polymarket/libs/syncData.js

@@ -0,0 +1,386 @@
+import 'dotenv/config';
+
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import Logs from "./logs.js";
+import { setData } from "./cache.js";
+import getDateInTimezone from "./getDateInTimezone.js";
+
+import { getSoccerSports, getEvents, platformPost, platformGet, MarketWsClient } from "./polymarketClient.js";
+import { parseMarkets, parseOddsAsk, parseOddsBid, parseIorDetail } from "./parseMarkets.js";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const IS_DEV = process.env.NODE_ENV == 'development';
+const IS_BID = process.env.PPAI_RUN_MODE == 'BID';
+
+const eventsCacheFile = path.join(__dirname, "../cache/polymarketEventsCache.json");
+const marketsDataFile = path.join(__dirname, "../cache/polymarketMarketsCache.json");
+
+const GLOBAL_DATA = {
+  eventsData: {},
+  marketsData: {},
+  clobTokenMap: {},
+  filteredLeagues: [],
+  relatedGames: [],
+  marketWsClient: null,
+  wsClientData: null,
+  lastChangeTime: 0,
+};
+
+// /**
+//  * 获取有效联赛
+//  * @param {*} marketsList
+//  * @param {*} soccerSports
+//  * @returns {Array}
+//  */
+// const getLeagues = (marketsList, soccerSports) => {
+//   const soccerSportsMap = new Map(soccerSports.map(item => [+item.series, item]));
+//   const leaguesList = marketsList.map(item => {
+//     const { leagueId: id, leagueName: name } = item;
+//     const sport = soccerSportsMap.get(+id)?.sport;
+//     return { id, name, sport };
+//   });
+//   // 去重并排序
+//   const leaguesMap = new Map(leaguesList.map(item => [item.id, item]));
+//   return Array.from(leaguesMap.values()).sort((a, b) => a.id - b.id);
+// }
+
+// /**
+//  * 获取足球联赛
+//  * @returns {Promise}
+//  */
+// const getSoccerSports = async () => {
+//   return fetchMarketData({ url: "/sports" })
+//   .then(sportsData => {
+//     return sportsData.filter(item => {
+//       const { tags } = item;
+//       const tagIds = tags.split(",").map(item => +item);
+//       return tagIds.includes(100350);
+//     });
+//   });
+// }
+
+// /**
+//  * 提交联赛和比赛数据
+//  * @param {string} platform - 平台名称
+//  * @param {string} url - 请求地址
+//  * @param {Object} data - 请求数据
+//  * @returns {Promise}
+//  */
+// const submitLeaguesAndGames = async ({ leagues, games }) => {
+//   const submitLeagues = platformPost('/api/platforms/update_leagues', { platform: 'polymarket', leagues });
+//   const submitGames = platformPost('/api/platforms/update_games', { platform: 'polymarket', games });
+//   return Promise.all([submitLeagues, submitGames]);
+// }
+
+// /**
+//  * 更新联赛和比赛数据
+//  */
+// const updateLeaguesAndGames = (marketsData) => {
+//   const marketsList = Object.values(marketsData);
+//   getSoccerSports()
+//   .then(soccerSports => {
+//     const leagues = getLeagues(marketsList, soccerSports);
+//     const games = marketsList.map(game => {
+//       const { marketsData, ...rest } = game;
+//       return rest;
+//     }).sort((a, b) => a.timestamp - b.timestamp);
+//     return { leagues, games };
+//   })
+//   .then(data => {
+//     Logs.outDev('update leagues and games list', data.leagues.length, data.games.length);
+//     return submitLeaguesAndGames(data);
+//   })
+//   .then(() => {
+//     Logs.outDev('leagues and games list updated');
+//   })
+//   .catch(error => {
+//     Logs.out('failed to update leagues and games list', error.message);
+//   });
+// }
+
+// /**
+//  * 获取过滤后的比赛数据
+//  * @returns
+//  */
+// const updateRelatedGames = () => {
+//   platformGet('/api/platforms/get_related_games', { platform: 'polymarket' })
+//   .then(res => {
+//     const { data: relatedGames } = res;
+//     Logs.outDev('relatedGames updated', relatedGames.length);
+//     GLOBAL_DATA.relatedGames = relatedGames.map(item => item.id);
+//   })
+//   .catch(error => {
+//     Logs.out('failed to update related games', error.message);
+//   })
+//   .finally(() => {
+//     setTimeout(() => {
+//       updateRelatedGames();
+//     }, 1000 * 10);
+//   });
+// }
+
+/**
+ * 获取赛事数据
+ * @returns {Promise}
+ */
+const getMarketsData = async () => {
+  const endDateMinStamps = Date.now() - 1000 * 60 * 60 * 2;
+  const endDateMin = new Date(endDateMinStamps).toISOString();
+  const tomorrowDateMinus4 = getDateInTimezone(-4, Date.now()+24*60*60*1000);
+  const tomorrowGmtMinus4EndTime = new Date(`${tomorrowDateMinus4} 23:59:59 GMT-4`).getTime();
+  const endDateMax = new Date(tomorrowGmtMinus4EndTime).toISOString();
+  return getEvents({ endDateMin, endDateMax })
+  .then(events => {
+    const { eventsData } = GLOBAL_DATA;
+    events.forEach(event => {
+      const { id } = event;
+      if (!eventsData[id]) {
+        eventsData[id] = event;
+      }
+    });
+    const eventsMap = new Map(events.map(event => [event.id, event]));
+    Object.keys(eventsData).forEach(id => {
+      if (!eventsMap.has(id)) {
+        delete eventsData[id];
+      }
+    });
+    setData(eventsCacheFile, eventsData);
+    return parseMarkets(eventsData);
+  })
+  .then(marketsData => {
+
+    // updateLeaguesAndGames(marketsData);
+
+    const { marketsData: oldMarketsData } = GLOBAL_DATA;
+    const newMarketsData = marketsData;
+
+    const marketsDataUpdate = {
+      add: [],
+      remove: []
+    }
+
+    Object.keys(oldMarketsData).forEach(id => {
+      if (!newMarketsData[id]) {
+        delete oldMarketsData[id];
+        marketsDataUpdate.remove.push(id);
+      }
+    });
+
+    Object.keys(newMarketsData).forEach(id => {
+      if (!oldMarketsData[id]) {
+        oldMarketsData[id] = newMarketsData[id];
+        marketsDataUpdate.add.push(id);
+      }
+    });
+
+    const clobToken = Object.values(oldMarketsData).map(item => item.marketsData)
+    .flatMap(item => Object.values(item))
+    .flatMap(item => Object.values(item))
+    .flatMap(item => Object.values(item.outcomes));
+
+    const { clobTokenMap: oldClobTokenMap } = GLOBAL_DATA;
+    const newClobTokenMap = new Map(clobToken.map(item => [item.id, item]));
+
+    const clobTokenUpdate = {
+      add: [],
+      remove: []
+    }
+
+    Object.keys(oldClobTokenMap).forEach(id => {
+      if (!newClobTokenMap.has(id)) {
+        delete oldClobTokenMap[id];
+        clobTokenUpdate.remove.push(id);
+      }
+    });
+
+    newClobTokenMap.forEach(item => {
+      const { id } = item;
+      if (!oldClobTokenMap[id]) {
+        oldClobTokenMap[id] = newClobTokenMap.get(id);
+        clobTokenUpdate.add.push(id);
+      }
+    });
+
+    return clobTokenUpdate;
+  });
+}
+
+/**
+ * 循环更新
+ */
+const updateGamesMarkets = () => {
+  Logs.outDev('updateGamesMarkets');
+  getMarketsData()
+  .then(clobTokenUpdate => {
+    const { marketWsClient } = GLOBAL_DATA;
+    const { add, remove } = clobTokenUpdate;
+    if (add.length > 0) {
+      Logs.outDev('subscribeToTokensIds', add);
+      marketWsClient?.subscribeToTokensIds(add);
+    }
+    if (remove.length > 0) {
+      Logs.outDev('unsubscribeToTokensIds', remove);
+      marketWsClient?.unsubscribeToTokensIds(remove);
+    }
+  })
+  .catch(error => {
+    Logs.out('failed to update games markets', error.message);
+  })
+  .finally(() => {
+    setTimeout(() => {
+      updateGamesMarkets();
+    }, 1000 * 60);
+  });
+}
+
+const syncMarketsData = () => {
+  const { marketsData } = GLOBAL_DATA;
+  setTimeout(() => {
+    syncMarketsData();
+  }, 1000 * 5);
+  if (IS_DEV) {
+    setData(marketsDataFile, marketsData);
+  }
+}
+
+/**
+ * 获取比赛盘口赔率数据
+ * @returns {Object}
+ */
+const getGamesEvents = () => {
+  const { marketsData, lastChangeTime } = GLOBAL_DATA;
+  // Logs.outDev('getGamesEvents', marketsData, lastChangeTime);
+  const games = Object.values(marketsData).map(item => {
+    const { marketsData, ...rest } = item;
+    const odds = IS_BID ? parseOddsBid(marketsData) : parseOddsAsk(marketsData);
+    // const odds = parseOddsBid(marketsData);
+    return { ...rest, odds };
+  }).sort((a, b) => a.timestamp - b.timestamp);
+  return { games, timestamp: lastChangeTime };
+}
+
+/**
+ * 更新赔率数据
+ */
+const updateOdds = async () => {
+  const { games, timestamp } = getGamesEvents();
+  const expireTime = Date.now() - 30_000;
+  if (!games.length || timestamp < expireTime ) {
+    return Promise.resolve();
+  }
+  Logs.outDev('updateOdds', games, timestamp);
+  return platformPost('/api/platforms/update_odds', { platform: 'polymarket', games, timestamp });
+}
+
+/**
+ * 定时更新赔率数据
+ */
+const updateOddsLoop = () => {
+  updateOdds()
+  .catch(error => {
+    Logs.err('failed to update odds', error.message);
+  })
+  .finally(() => {
+    setTimeout(() => {
+      updateOddsLoop();
+    }, 1000 * 2);
+  });
+}
+
+/**
+ * 处理价格变化
+ * @param {*} priceChanges
+ */
+const marketPriceChange = (priceChanges) => {
+  let changed = false;
+  priceChanges.forEach(priceChange => {
+    const { asset_id, best_ask, best_bid } = priceChange;
+    const { clobTokenMap } = GLOBAL_DATA;
+    const clobToken = clobTokenMap[asset_id];
+    if (!clobToken) {
+      return;
+    }
+    Object.assign(clobToken, { best_ask, best_bid });
+    changed = true;
+  });
+  if (changed) {
+    GLOBAL_DATA.lastChangeTime = Date.now();
+  }
+}
+
+/**
+ * 处理市场数据
+ */
+const marketBook = (data) => {
+  const { asks, bids, asset_id } = data;
+  const { clobTokenMap } = GLOBAL_DATA;
+  const clobToken = clobTokenMap[asset_id];
+  if (!clobToken) {
+    return;
+  }
+  const bestAskItem = asks?.reduce((minItem, current) => {
+    return Number(current.price) < Number(minItem.price) ? current : minItem;
+  }, asks?.[0] ?? { price: '1' });
+  const best_ask = bestAskItem?.price ?? '0.001';
+  const bestBidItem = bids?.reduce((maxItem, current) => {
+    return Number(current.price) > Number(maxItem.price) ? current : maxItem;
+  }, bids?.[0] ?? { price: '0' });
+  const best_bid = bestBidItem?.price ?? '0';
+  Object.assign(clobToken, { best_ask, best_bid });
+  GLOBAL_DATA.lastChangeTime = Date.now();
+}
+
+/**
+ * 启动同步市场数据
+ */
+export const startSyncMarketsData = () => {
+  const marketWsClient = new MarketWsClient();
+  GLOBAL_DATA.marketWsClient = marketWsClient;
+  marketWsClient.connect();
+  marketWsClient.on('open', () => {
+    // updateRelatedGames();
+    updateGamesMarkets();
+    syncMarketsData();
+    updateOddsLoop();
+  });
+  marketWsClient.on('message', data => {
+    // Logs.outDev('message data:', data);
+    switch(data.event_type) {
+      case 'price_change':
+        marketPriceChange(data.price_changes);
+        break;
+      case 'book':
+        marketBook(data);
+        break
+    }
+  });
+  marketWsClient.on('error', error => {
+    Logs.err('error', error);
+  });
+  marketWsClient.on('close', event => {
+    Logs.outDev('close', event);
+  });
+}
+
+/**
+ * 获取盘口详情
+ * @param {*} ior
+ * @param {*} id
+ * @returns
+ */
+export const getIorInfo = (ior, id) => {
+  if (!id || !ior) {
+    return Promise.reject({ cause: 400, message: 'id and ior are required', data: { id, ior } });
+  }
+  const { marketsData } = GLOBAL_DATA;
+  const iorInfo = parseIorDetail(ior, id, marketsData);
+  if (iorInfo.cause === 400) {
+    return Promise.reject({ cause: 400, message: iorInfo.message, data: { id, ior } });
+  }
+  Logs.outDev('getIorInfo', { id, ior }, iorInfo);
+  return Promise.resolve(iorInfo);
+}

+ 56 - 342
polymarket/main.js

@@ -1,359 +1,73 @@
-import path from "path";
-import { fileURLToPath } from "url";
+import 'dotenv/config';
 
-import Logs from "./libs/logs.js";
-import { setData } from "./libs/cache.js";
-import getDateInTimezone from "./libs/getDateInTimezone.js";
-
-import { getSoccerSports, getEvents, platformPost, platformGet, MarketWsClient } from "./libs/polymarketClient.js";
-import { parseMarkets, parseOdds } from "./libs/parseMarkets.js";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-const eventsCacheFile = path.join(__dirname, "./cache/polymarketEventsCache.json");
-const marketsDataFile = path.join(__dirname, "./cache/polymarketMarketsCache.json");
-
-const GLOBAL_DATA = {
-  eventsData: {},
-  marketsData: {},
-  clobTokenMap: {},
-  filteredLeagues: [],
-  relatedGames: [],
-  marketWsClient: null,
-  wsClientData: null,
-  lastChangeTime: 0,
-};
-
-// /**
-//  * 获取有效联赛
-//  * @param {*} marketsList
-//  * @param {*} soccerSports
-//  * @returns {Array}
-//  */
-// const getLeagues = (marketsList, soccerSports) => {
-//   const soccerSportsMap = new Map(soccerSports.map(item => [+item.series, item]));
-//   const leaguesList = marketsList.map(item => {
-//     const { leagueId: id, leagueName: name } = item;
-//     const sport = soccerSportsMap.get(+id)?.sport;
-//     return { id, name, sport };
-//   });
-//   // 去重并排序
-//   const leaguesMap = new Map(leaguesList.map(item => [item.id, item]));
-//   return Array.from(leaguesMap.values()).sort((a, b) => a.id - b.id);
-// }
-
-// /**
-//  * 获取足球联赛
-//  * @returns {Promise}
-//  */
-// const getSoccerSports = async () => {
-//   return fetchMarketData({ url: "/sports" })
-//   .then(sportsData => {
-//     return sportsData.filter(item => {
-//       const { tags } = item;
-//       const tagIds = tags.split(",").map(item => +item);
-//       return tagIds.includes(100350);
-//     });
-//   });
-// }
-
-// /**
-//  * 提交联赛和比赛数据
-//  * @param {string} platform - 平台名称
-//  * @param {string} url - 请求地址
-//  * @param {Object} data - 请求数据
-//  * @returns {Promise}
-//  */
-// const submitLeaguesAndGames = async ({ leagues, games }) => {
-//   const submitLeagues = platformPost('/api/platforms/update_leagues', { platform: 'polymarket', leagues });
-//   const submitGames = platformPost('/api/platforms/update_games', { platform: 'polymarket', games });
-//   return Promise.all([submitLeagues, submitGames]);
-// }
+import express from 'express';
 
-// /**
-//  * 更新联赛和比赛数据
-//  */
-// const updateLeaguesAndGames = (marketsData) => {
-//   const marketsList = Object.values(marketsData);
-//   getSoccerSports()
-//   .then(soccerSports => {
-//     const leagues = getLeagues(marketsList, soccerSports);
-//     const games = marketsList.map(game => {
-//       const { marketsData, ...rest } = game;
-//       return rest;
-//     }).sort((a, b) => a.timestamp - b.timestamp);
-//     return { leagues, games };
-//   })
-//   .then(data => {
-//     Logs.outDev('update leagues and games list', data.leagues.length, data.games.length);
-//     return submitLeaguesAndGames(data);
-//   })
-//   .then(() => {
-//     Logs.outDev('leagues and games list updated');
-//   })
-//   .catch(error => {
-//     Logs.out('failed to update leagues and games list', error.message);
-//   });
-// }
+import Logs from "./libs/logs.js";
+import requireInternalToken from './middleware/requireInternalToken.js';
+import tradingRoutes from './routes/trading.js';
 
-// /**
-//  * 获取过滤后的比赛数据
-//  * @returns
-//  */
-// const updateRelatedGames = () => {
-//   platformGet('/api/platforms/get_related_games', { platform: 'polymarket' })
-//   .then(res => {
-//     const { data: relatedGames } = res;
-//     Logs.outDev('relatedGames updated', relatedGames.length);
-//     GLOBAL_DATA.relatedGames = relatedGames.map(item => item.id);
-//   })
-//   .catch(error => {
-//     Logs.out('failed to update related games', error.message);
-//   })
-//   .finally(() => {
-//     setTimeout(() => {
-//       updateRelatedGames();
-//     }, 1000 * 10);
-//   });
-// }
+const app = express();
 
-/**
- * 获取赛事数据
- * @returns {Promise}
- */
-const getMarketsData = async () => {
-  const endDateMinStamps = Date.now() - 1000 * 60 * 60 * 2;
-  const endDateMin = new Date(endDateMinStamps).toISOString();
-  const tomorrowDateMinus4 = getDateInTimezone(-4, Date.now()+24*60*60*1000);
-  const tomorrowGmtMinus4EndTime = new Date(`${tomorrowDateMinus4} 23:59:59 GMT-4`).getTime();
-  const endDateMax = new Date(tomorrowGmtMinus4EndTime).toISOString();
-  return getEvents({ endDateMin, endDateMax })
-  .then(events => {
-    const { eventsData } = GLOBAL_DATA;
-    events.forEach(event => {
-      const { id } = event;
-      if (!eventsData[id]) {
-        eventsData[id] = event;
-      }
-    });
-    const eventsMap = new Map(events.map(event => [event.id, event]));
-    Object.keys(eventsData).forEach(id => {
-      if (!eventsMap.has(id)) {
-        delete eventsData[id];
-      }
-    });
-    setData(eventsCacheFile, eventsData);
-    return parseMarkets(eventsData);
-  })
-  .then(marketsData => {
+app.use((req, res, next) => {
+  const origin = req.headers.origin;
+  res.header('Access-Control-Allow-Origin', origin || '*');
+  res.header('Access-Control-Allow-Credentials', 'true');
+  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Internal-Token');
+  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+  res.header('Vary', 'Origin');
 
-    // updateLeaguesAndGames(marketsData);
+  if (req.method === 'OPTIONS') {
+    return res.sendStatus(200);
+  }
+  next();
+});
 
-    const { marketsData: oldMarketsData } = GLOBAL_DATA;
-    const newMarketsData = marketsData;
+app.use(express.json({ limit: '10mb' }));
 
-    const marketsDataUpdate = {
-      add: [],
-      remove: []
+app.use((req, res, next) => {
+  res.badRequest = (data, msg) => {
+    if (!msg && typeof data === 'string') {
+      msg = data;
+      data = undefined;
     }
-
-    Object.keys(oldMarketsData).forEach(id => {
-      if (!newMarketsData[id]) {
-        delete oldMarketsData[id];
-        marketsDataUpdate.remove.push(id);
-      }
-    });
-
-    Object.keys(newMarketsData).forEach(id => {
-      if (!oldMarketsData[id]) {
-        oldMarketsData[id] = newMarketsData[id];
-        marketsDataUpdate.add.push(id);
-      }
-    });
-
-    const clobToken = Object.values(oldMarketsData).map(item => item.marketsData)
-    .flatMap(item => Object.values(item))
-    .flatMap(item => Object.values(item))
-    .flatMap(item => Object.values(item.outcomes));
-
-    const { clobTokenMap: oldClobTokenMap } = GLOBAL_DATA;
-    const newClobTokenMap = new Map(clobToken.map(item => [item.id, item]));
-
-    const clobTokenUpdate = {
-      add: [],
-      remove: []
+    return res.status(400).json({ statusCode: 400, code: -1, message: msg ?? 'Bad Request', data });
+  }
+  res.serverError = (data, msg) => {
+    if (!msg && typeof data === 'string') {
+      msg = data;
+      data = undefined;
     }
-
-    Object.keys(oldClobTokenMap).forEach(id => {
-      if (!newClobTokenMap.has(id)) {
-        delete oldClobTokenMap[id];
-        clobTokenUpdate.remove.push(id);
-      }
-    });
-
-    newClobTokenMap.forEach(item => {
-      const { id } = item;
-      if (!oldClobTokenMap[id]) {
-        oldClobTokenMap[id] = newClobTokenMap.get(id);
-        clobTokenUpdate.add.push(id);
-      }
-    });
-
-    return clobTokenUpdate;
-  });
-}
-
-/**
- * 循环更新
- */
-const updateGamesMarkets = () => {
-  Logs.outDev('updateGamesMarkets');
-  getMarketsData()
-  .then(clobTokenUpdate => {
-    const { marketWsClient } = GLOBAL_DATA;
-    const { add, remove } = clobTokenUpdate;
-    if (add.length > 0) {
-      Logs.outDev('subscribeToTokensIds', add);
-      marketWsClient?.subscribeToTokensIds(add);
+    return res.status(500).json({ statusCode: 500, code: -1, message: msg ?? 'Internal Server Error', data });
+  }
+  res.unauthorized = (data, msg) => {
+    if (!msg && typeof data === 'string') {
+      msg = data;
+      data = undefined;
     }
-    if (remove.length > 0) {
-      Logs.outDev('unsubscribeToTokensIds', remove);
-      marketWsClient?.unsubscribeToTokensIds(remove);
+    return res.status(401).json({ statusCode: 401, code: -1, message: msg ?? 'Unauthorized', data });
+  }
+  res.sendSuccess = (data, msg) => {
+    const response = { statusCode: 200, code: 0, message: msg ?? 'OK' }
+    if (data !== undefined) {
+      response.data = data;
     }
-  })
-  .catch(error => {
-    Logs.out('failed to update games markets', error.message);
-  })
-  .finally(() => {
-    setTimeout(() => {
-      updateGamesMarkets();
-    }, 1000 * 60);
-  });
-}
-
-const syncMarketsData = () => {
-  const { marketsData } = GLOBAL_DATA;
-  // Logs.outDev('syncMarketsData', marketsData, clobTokenMap);
-  setData(marketsDataFile, marketsData);
-  setTimeout(() => {
-    syncMarketsData();
-  }, 1000 * 5);
-}
-
-/**
- * 获取比赛盘口赔率数据
- * @returns {Object}
- */
-const getGamesEvents = () => {
-  const { marketsData, lastChangeTime } = GLOBAL_DATA;
-  // Logs.outDev('getGamesEvents', marketsData, lastChangeTime);
-  const games = Object.values(marketsData).map(item => {
-    const { marketsData, ...rest } = item;
-    const odds = parseOdds(marketsData);
-    return { ...rest, odds };
-  }).sort((a, b) => a.timestamp - b.timestamp);
-  return { games, timestamp: lastChangeTime };
-}
-
-/**
- * 更新赔率数据
- */
-const updateOdds = async () => {
-  const { games, timestamp } = getGamesEvents();
-  const expireTime = Date.now() - 30_000;
-  if (!games.length || timestamp < expireTime ) {
-    return Promise.resolve();
+    return res.status(200).json(response);
   }
-  // Logs.outDev('updateOdds', games, timestamp);
-  return platformPost('/api/platforms/update_odds', { platform: 'polymarket', games, timestamp });
-}
-
-/**
- * 定时更新赔率数据
- */
-const updateOddsLoop = () => {
-  updateOdds()
-  .catch(error => {
-    Logs.err('failed to update odds', error.message);
-  })
-  .finally(() => {
-    setTimeout(() => {
-      updateOddsLoop();
-    }, 1000 * 2);
-  });
-}
-
-/**
- * 处理价格变化
- * @param {*} priceChanges
- */
-const marketPriceChange = (priceChanges) => {
-  let changed = false;
-  priceChanges.forEach(priceChange => {
-    const { asset_id, best_ask, best_bid } = priceChange;
-    const { clobTokenMap } = GLOBAL_DATA;
-    const clobToken = clobTokenMap[asset_id];
-    if (!clobToken) {
-      return;
+  res.sendError = (err) => {
+    if (err.cause === 400 || err.status === 400) {
+      return res.badRequest(err.data, err.message);
     }
-    Object.assign(clobToken, { best_ask, best_bid });
-    changed = true;
-  });
-  if (changed) {
-    GLOBAL_DATA.lastChangeTime = Date.now();
+    return res.serverError(err.data, err.message);
   }
-}
+  next();
+});
 
-/**
- * 处理市场数据
- */
-const marketBook = (data) => {
-  const { asks, bids, asset_id } = data;
-  const { clobTokenMap } = GLOBAL_DATA;
-  const clobToken = clobTokenMap[asset_id];
-  if (!clobToken) {
-    return;
-  }
-  const bestAskItem = asks?.reduce((minItem, current) => {
-    return Number(current.price) < Number(minItem.price) ? current : minItem;
-  }, asks?.[0] ?? { price: '1' });
-  const best_ask = bestAskItem?.price ?? '0.001';
-  const bestBidItem = bids?.reduce((maxItem, current) => {
-    return Number(current.price) > Number(maxItem.price) ? current : maxItem;
-  }, bids?.[0] ?? { price: '0' });
-  const best_bid = bestBidItem?.price ?? '0';
-  Object.assign(clobToken, { best_ask, best_bid });
-  GLOBAL_DATA.lastChangeTime = Date.now();
-}
+app.get('/health', (req, res) => {
+  res.sendSuccess({ service: 'polymarket', status: 'ok' });
+});
 
-const main = () => {
-  const marketWsClient = new MarketWsClient();
-  GLOBAL_DATA.marketWsClient = marketWsClient;
-  marketWsClient.connect();
-  marketWsClient.on('open', () => {
-    // updateRelatedGames();
-    updateGamesMarkets();
-    syncMarketsData();
-    updateOddsLoop();
-  });
-  marketWsClient.on('message', data => {
-    // Logs.outDev('message data:', data);
-    switch(data.event_type) {
-      case 'price_change':
-        marketPriceChange(data.price_changes);
-        break;
-      case 'book':
-        marketBook(data);
-        break
-    }
-  });
-  marketWsClient.on('error', error => {
-    Logs.err('error', error);
-  });
-  marketWsClient.on('close', event => {
-    Logs.outDev('close', event);
-  });
-}
+app.use('/api/trading', requireInternalToken, tradingRoutes);
 
-main();
+// 启动服务
+const PORT = process.env.PORT || 9021;
+app.listen(PORT, () => Logs.out(`Polymarket service running on port ${PORT}`));

+ 19 - 0
polymarket/middleware/requireInternalToken.js

@@ -0,0 +1,19 @@
+import { safeEqual } from '../libs/auth.js';
+
+const getBearerToken = (authorization = '') => {
+  const [scheme, token] = authorization.split(' ');
+  return scheme?.toLowerCase() === 'bearer' ? token : '';
+};
+
+const requireInternalToken = (req, res, next) => {
+  const expectedToken = process.env.PPAI_INTERNAL_API_TOKEN;
+  const providedToken = req.get('x-internal-token') || getBearerToken(req.get('authorization'));
+
+  if (expectedToken && safeEqual(providedToken, expectedToken)) {
+    return next();
+  }
+
+  return res.unauthorized('Invalid internal token');
+};
+
+export default requireInternalToken;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 855 - 45
polymarket/package-lock.json


+ 11 - 2
polymarket/package.json

@@ -6,17 +6,26 @@
   "type": "module",
   "scripts": {
     "dev": "nodemon --ignore data/ --ignore cache/ --ignore node_modules/ --inspect=9227 main.js",
+    "apikey": "node apikey.js",
+    "balance": "node balance.js",
+    "deposit": "node deposit.js",
+    "transfer": "node transfer.js",
+    "whoami": "node whoami.js",
     "start": "pm2 start main.js --name ppai-polymarket"
   },
   "author": "",
   "license": "ISC",
   "dependencies": {
-    "@polymarket/clob-client": "^5.2.1",
+    "@polymarket/builder-relayer-client": "^0.0.9",
+    "@polymarket/builder-signing-sdk": "^1.0.0",
+    "@polymarket/clob-client-v2": "^1.0.6",
     "axios": "^1.13.3",
     "dayjs": "^1.11.19",
-    "ethers": "^5.8.0",
+    "dotenv": "^17.4.2",
+    "express": "^5.2.1",
     "https-proxy-agent": "^7.0.6",
     "qs": "^6.14.1",
+    "viem": "^2.50.3",
     "ws": "^8.19.0"
   }
 }

+ 127 - 0
polymarket/routes/trading.js

@@ -0,0 +1,127 @@
+import express from 'express';
+
+import { startSyncMarketsData, getIorInfo } from "../libs/syncData.js";
+import { getLineInfo } from "../libs/pinnacleClient.js";
+import Logs from "../libs/logs.js";
+
+import {
+  createLimitOrder,
+  getBalanceAllowance,
+  getMultipleOrderBooks,
+  getOrderBook,
+  transferWallet,
+} from "../libs/polymarketClient.js";
+
+const router = express.Router();
+
+router.get('/balance/:wallet', (req, res) => {
+  const { wallet } = req.params;
+  if (wallet !== "both" && wallet !== "proxy" && wallet !== "deposit") {
+    return res.badRequest('invalid wallet');
+  }
+  getBalanceAllowance({ wallet })
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('get balance allowance error', error);
+    return res.sendError(error);
+  });
+});
+
+router.post('/wallet/transfer', (req, res) => {
+  const { amount, from, to } = req.body;
+  if (!amount) {
+    return res.badRequest('amount is required');
+  }
+  if (!from) {
+    return res.badRequest('from is required');
+  }
+  if (!to) {
+    return res.badRequest('to is required');
+  }
+
+  transferWallet({ amount, from, to })
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('transfer wallet error', error);
+    return res.sendError(error);
+  });
+});
+
+router.get('/get_ior_info/:id/:ior', (req, res) => {
+  const { id, ior } = req.params;
+  getIorInfo(ior, id)
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('get ior info error', error);
+    return res.sendError(error);
+  });
+});
+
+router.get('/orderbook/:tokenId', (req, res) => {
+  getOrderBook(req.params.tokenId)
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('get order book error', error);
+    return res.sendError(error);
+  });
+});
+
+router.post('/get_line_info', (req, res) => {
+  getLineInfo(req.body)
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('get line info error', error);
+    return res.sendError(error);
+  });
+});
+
+router.post('/orderbooks', (req, res) => {
+  const { tokenIds } = req.body;
+  if (!Array.isArray(tokenIds) || tokenIds.length === 0) {
+    return res.badRequest('tokenIds is required');
+  }
+  getMultipleOrderBooks(tokenIds)
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('get multiple order books error', error);
+    return res.sendError(error);
+  });
+});
+
+router.post('/orders/limit', (req, res) => {
+  const {
+    tokenID,
+    tokenId,
+    price,
+    size,
+    side,
+    tickSize = "0.01",
+    negRisk = false,
+    orderType,
+    // postOnly = false,
+    expiration,
+    deferExec = false,
+  } = req.body;
+
+  createLimitOrder({
+    tokenID: tokenID || tokenId,
+    price,
+    size,
+    side,
+    tickSize,
+    negRisk,
+    orderType,
+    // postOnly,
+    expiration,
+    deferExec,
+  })
+  .then(data => res.sendSuccess(data))
+  .catch(error => {
+    Logs.errDev('create limit order error', error);
+    return res.sendError(error);
+  });
+});
+
+startSyncMarketsData();
+
+export default router;

+ 303 - 0
polymarket/transfer.js

@@ -0,0 +1,303 @@
+import 'dotenv/config';
+
+import axios from "axios";
+import { HttpsProxyAgent } from "https-proxy-agent";
+import { BuilderConfig } from "@polymarket/builder-signing-sdk";
+import {
+  deriveProxyWallet,
+  RelayerTxType,
+  RelayClient,
+} from "@polymarket/builder-relayer-client";
+import {
+  createPublicClient,
+  createWalletClient,
+  encodeFunctionData,
+  erc20Abi,
+  formatUnits,
+  http,
+  maxUint256,
+  parseUnits,
+} from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+import { polygon } from "viem/chains";
+
+const CHAIN_ID = 137;
+const PUSD_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB";
+const PUSD_DECIMALS = 6;
+const PROXY_FACTORY_ADDRESS = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052";
+const DEFAULT_CLOB_SPENDERS = [
+  "0xE111180000d2663C0091e4f400237545B87B996B",
+  "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296",
+  "0xe2222d279d744050d28e00520010520000310F59",
+];
+
+const NODE_HTTP_PROXY = process.env.NODE_HTTP_PROXY;
+const proxyAgent = NODE_HTTP_PROXY ? new HttpsProxyAgent(NODE_HTTP_PROXY) : undefined;
+if (NODE_HTTP_PROXY) {
+  axios.defaults.proxy = false;
+  axios.defaults.httpAgent = proxyAgent;
+  axios.defaults.httpsAgent = proxyAgent;
+}
+
+const getRequiredEnv = (key) => {
+  const value = process.env[key];
+  if (!value) {
+    throw new Error(`${key} is required`);
+  }
+  return value;
+}
+
+const getOptionalEnv = (key) => {
+  const value = process.env[key];
+  return value && value.trim() ? value.trim() : undefined;
+}
+
+const normalizePrivateKey = (privateKey) => {
+  return privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`;
+}
+
+const getArgValue = (name) => {
+  const prefix = `${name}=`;
+  const arg = process.argv.slice(2).find(item => item.startsWith(prefix));
+  return arg ? arg.slice(prefix.length) : undefined;
+}
+
+const normalizeDirection = () => {
+  const from = (getArgValue("--from") || process.env.TRANSFER_FROM || "proxy").toLowerCase();
+  const to = (getArgValue("--to") || process.env.TRANSFER_TO || "deposit").toLowerCase();
+  if (!["proxy", "deposit"].includes(from) || !["proxy", "deposit"].includes(to) || from === to) {
+    throw new Error("Transfer direction must be proxy -> deposit or deposit -> proxy");
+  }
+  return { from, to };
+}
+
+const normalizeAction = () => {
+  const action = (getArgValue("--action") || process.env.TRANSFER_ACTION || "transfer").toLowerCase();
+  if (!["transfer", "approve"].includes(action)) {
+    throw new Error("action must be transfer or approve");
+  }
+  return action;
+}
+
+const normalizeWalletMode = () => {
+  const wallet = (getArgValue("--wallet") || process.env.APPROVE_WALLET || "deposit").toLowerCase();
+  if (!["proxy", "deposit", "both"].includes(wallet)) {
+    throw new Error("wallet must be proxy, deposit, or both");
+  }
+  return wallet;
+}
+
+const getClobSpenders = () => {
+  const configured = getArgValue("--spenders") || process.env.CLOB_SPENDERS;
+  if (!configured) {
+    return DEFAULT_CLOB_SPENDERS;
+  }
+
+  const spenders = configured.split(",").map(item => item.trim()).filter(Boolean);
+  if (!spenders.length) {
+    throw new Error("CLOB spenders cannot be empty");
+  }
+  return spenders;
+}
+
+const action = normalizeAction();
+const amount = getArgValue("--amount") || process.env.TRANSFER_AMOUNT;
+if (action === "transfer" && (!amount || Number(amount) <= 0)) {
+  throw new Error("Transfer amount is required. Example: npm run transfer -- --amount=10");
+}
+
+const direction = action === "transfer" ? normalizeDirection() : undefined;
+const walletMode = action === "approve" ? normalizeWalletMode() : undefined;
+const clobSpenders = getClobSpenders();
+const execute = process.argv.includes("--execute");
+const relayerUrl = getOptionalEnv("POLYMARKET_RELAYER_URL") || "https://relayer-v2.polymarket.com";
+const rpcUrl = getOptionalEnv("POLYGON_RPC_URL");
+const account = privateKeyToAccount(normalizePrivateKey(getRequiredEnv("POLYMARKET_PRIVATE_KEY")));
+const walletTransport = rpcUrl ? http(rpcUrl) : http();
+const signer = createWalletClient({ account, chain: polygon, transport: walletTransport });
+const publicClient = createPublicClient({ chain: polygon, transport: walletTransport });
+const builderConfig = new BuilderConfig({
+  localBuilderCreds: {
+    key: getRequiredEnv("POLYMARKET_BUILDER_API_KEY"),
+    secret: getRequiredEnv("POLYMARKET_BUILDER_SECRET"),
+    passphrase: getRequiredEnv("POLYMARKET_BUILDER_PASS_PHRASE"),
+  },
+});
+
+const createRelayer = (relayTxType = RelayerTxType.PROXY) => {
+  const relayer = new RelayClient(relayerUrl, CHAIN_ID, signer, builderConfig, relayTxType);
+  if (proxyAgent) {
+    relayer.httpClient.instance.defaults.proxy = false;
+    relayer.httpClient.instance.defaults.httpAgent = proxyAgent;
+    relayer.httpClient.instance.defaults.httpsAgent = proxyAgent;
+  }
+  return relayer;
+}
+
+const proxyRelayer = createRelayer(RelayerTxType.PROXY);
+const proxyWalletAddress = getOptionalEnv("POLYMARKET_PROXY_WALLET_ADDRESS")
+  || deriveProxyWallet(account.address, PROXY_FACTORY_ADDRESS);
+const depositWalletAddress = getOptionalEnv("POLYMARKET_DEPOSIT_WALLET_ADDRESS")
+  || await proxyRelayer.deriveDepositWalletAddress();
+const [proxyBalance, depositBalance] = await Promise.all([
+  publicClient.readContract({
+    address: PUSD_ADDRESS,
+    abi: erc20Abi,
+    functionName: "balanceOf",
+    args: [proxyWalletAddress],
+  }),
+  publicClient.readContract({
+    address: PUSD_ADDRESS,
+    abi: erc20Abi,
+    functionName: "balanceOf",
+    args: [depositWalletAddress],
+  }),
+]);
+
+const baseResult = {
+  owner: account.address,
+  proxyWalletAddress,
+  depositWalletAddress,
+  pUSD: PUSD_ADDRESS,
+  proxyBalance: formatUnits(proxyBalance, PUSD_DECIMALS),
+  depositBalance: formatUnits(depositBalance, PUSD_DECIMALS),
+  execute,
+};
+
+const createApproveData = (spender) => {
+  return encodeFunctionData({
+    abi: erc20Abi,
+    functionName: "approve",
+    args: [spender, maxUint256],
+  });
+}
+
+const runApprove = async () => {
+  const wallets = [];
+  if (walletMode === "proxy" || walletMode === "both") {
+    wallets.push({
+      type: "proxy",
+      address: proxyWalletAddress,
+      calls: clobSpenders.map(spender => ({
+        to: PUSD_ADDRESS,
+        data: createApproveData(spender),
+        value: "0",
+      })),
+    });
+  }
+
+  if (walletMode === "deposit" || walletMode === "both") {
+    wallets.push({
+      type: "deposit",
+      address: depositWalletAddress,
+      calls: clobSpenders.map(spender => ({
+        target: PUSD_ADDRESS,
+        data: createApproveData(spender),
+        value: "0",
+      })),
+    });
+  }
+
+  console.log(JSON.stringify({
+    ...baseResult,
+    action,
+    wallet: walletMode,
+    spenders: clobSpenders,
+    approvals: wallets.map(item => ({
+      type: item.type,
+      address: item.address,
+      spenderCount: item.calls.length,
+    })),
+  }, null, 2));
+
+  if (!execute) {
+    console.log(`Dry run only. Add --execute to submit CLOB approvals for ${walletMode} wallet.`);
+    return;
+  }
+
+  const results = [];
+  for (const wallet of wallets) {
+    const response = wallet.type === "proxy"
+      ? await proxyRelayer.execute(wallet.calls, "approve pUSD for CLOB")
+      : await createRelayer().executeDepositWalletBatch(
+        wallet.calls,
+        depositWalletAddress,
+        String(Math.floor(Date.now() / 1000) + 600),
+      );
+    const confirmed = await response.wait();
+    results.push({
+      type: wallet.type,
+      address: wallet.address,
+      transactionID: response.transactionID,
+      transactionHash: response.transactionHash || response.hash,
+      confirmed,
+    });
+  }
+
+  console.log(JSON.stringify({ results }, null, 2));
+}
+
+const runTransfer = async () => {
+  const transferAmount = parseUnits(amount, PUSD_DECIMALS);
+  const result = {
+    ...baseResult,
+    action,
+    from: direction.from,
+    to: direction.to,
+    sourceAddress: direction.from === "proxy" ? proxyWalletAddress : depositWalletAddress,
+    destinationAddress: direction.to === "deposit" ? depositWalletAddress : proxyWalletAddress,
+    amount,
+  };
+
+  console.log(JSON.stringify(result, null, 2));
+
+  const sourceBalance = direction.from === "proxy" ? proxyBalance : depositBalance;
+  if (sourceBalance < transferAmount) {
+    throw new Error(`Insufficient ${direction.from} wallet pUSD balance: ${formatUnits(sourceBalance, PUSD_DECIMALS)}`);
+  }
+
+  if (!execute) {
+    console.log(`Dry run only. Add --execute to submit the ${direction.from} -> ${direction.to} transfer.`);
+    return;
+  }
+
+  const data = encodeFunctionData({
+    abi: erc20Abi,
+    functionName: "transfer",
+    args: [result.destinationAddress, transferAmount],
+  });
+  const deadline = String(Math.floor(Date.now() / 1000) + 600);
+  const depositRelayer = createRelayer();
+  const response = direction.from === "proxy"
+    ? await proxyRelayer.execute(
+      [{
+        to: PUSD_ADDRESS,
+        data,
+        value: "0",
+      }],
+      `transfer ${amount} pUSD from proxy to deposit wallet`,
+    )
+    : await depositRelayer.executeDepositWalletBatch(
+      [{
+        target: PUSD_ADDRESS,
+        data,
+        value: "0",
+      }],
+      depositWalletAddress,
+      deadline,
+    );
+  const confirmed = await response.wait();
+
+  console.log(JSON.stringify({
+    transactionID: response.transactionID,
+    transactionHash: response.transactionHash || response.hash,
+    confirmed,
+  }, null, 2));
+}
+
+if (action === "approve") {
+  await runApprove();
+}
+else {
+  await runTransfer();
+}

+ 6 - 5
server/libs/auth.js

@@ -21,7 +21,7 @@ const sign = (payload, secret) => {
   return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
 };
 
-const safeEqual = (a = '', b = '') => {
+export const safeEqual = (a = '', b = '') => {
   const aBuffer = Buffer.from(a);
   const bBuffer = Buffer.from(b);
 
@@ -44,10 +44,11 @@ export const cookieOptions = () => {
   };
 };
 
-export const clearCookieOptions = () => ({
-  ...cookieOptions(),
-  maxAge: 0,
-});
+export const clearCookieOptions = () => {
+  const { maxAge, ...options } = cookieOptions();
+
+  return options;
+};
 
 export const createSession = (username) => {
   const { secret, maxAge } = getConfig();

+ 65 - 0
server/libs/platformRequest.js

@@ -0,0 +1,65 @@
+import axios from "axios";
+
+/**
+ * axios 默认配置
+ */
+const axiosDefaultOptions = {
+  baseURL: "",
+  url: "",
+  method: "GET",
+  headers: {},
+  params: {},
+  data: {},
+  timeout: 10000,
+};
+
+/**
+ * 请求平台数据
+ * @param {*} options
+ * @returns {Promise}
+ */
+export const platformRequest = async (options) => {
+  const { url } = options;
+  if (!url) {
+    throw new Error("url is required");
+  }
+  const internalToken = process.env.PPAI_INTERNAL_API_TOKEN;
+  const mergedOptions = {
+    ...axiosDefaultOptions,
+    ...options,
+    headers: {
+      ...axiosDefaultOptions.headers,
+      ...options.headers,
+      ...(internalToken ? { Authorization: `Bearer ${internalToken}` } : {}),
+    },
+    proxy: false,
+  };
+  return axios(mergedOptions).then(res => res.data);
+}
+
+/**
+ * 请求平台 POST 数据
+ * @param {string} url
+ * @param {Object} data
+ * @returns {Promise}
+ */
+export const platformPost = async (url, data) => {
+  return platformRequest({
+    url,
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    data,
+  });
+}
+
+/**
+ * 请求平台 GET 数据
+ * @param {string} url
+ * @param {Object} params
+ * @returns {Promise}
+ */
+export const platformGet = async (url, params) => {
+  return platformRequest({ url, method: 'GET', params });
+}

+ 9 - 8
server/main.js

@@ -1,25 +1,27 @@
+import 'dotenv/config';
+
 import express from 'express';
 import expressWs from 'express-ws';
-import dotenv from 'dotenv';
 import cookieParser from 'cookie-parser';
 import Logs from './libs/logs.js';
 import authRoutes from './routes/auth.js';
 import gamesRoutes from './routes/games.js';
 import localesRoutes from './routes/locales.js';
 import platformsRoutes from './routes/platforms.js';
-import partnerGateRoutes from './routes/partnerGate.js';
 import requireAuth from './middleware/requireAuth.js';
+import requireInternalToken from './middleware/requireInternalToken.js';
 
 const app = express();
 const wsInstance = expressWs(app);
 
-dotenv.config();
-
 // 添加 CORS 支持.env
 app.use((req, res, next) => {
-  res.header('Access-Control-Allow-Origin', '*');
-  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
+  const origin = req.headers.origin;
+  res.header('Access-Control-Allow-Origin', origin || '*');
+  res.header('Access-Control-Allow-Credentials', 'true');
+  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Internal-Token');
   res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+  res.header('Vary', 'Origin');
 
   if (req.method === 'OPTIONS') {
     return res.sendStatus(200);
@@ -87,8 +89,7 @@ app.use((req, res, next) => {
 app.use('/api/auth', authRoutes);
 app.use('/api/games', requireAuth, gamesRoutes);
 app.use('/api/locales', requireAuth, localesRoutes);
-app.use('/api/platforms', requireAuth, platformsRoutes);
-app.use('/api/partner', partnerGateRoutes);
+app.use('/api/platforms', requireInternalToken, platformsRoutes);
 
 // 启动服务
 const PORT = process.env.PORT || 9020;

+ 19 - 0
server/middleware/requireInternalToken.js

@@ -0,0 +1,19 @@
+import { safeEqual } from '../libs/auth.js';
+
+const getBearerToken = (authorization = '') => {
+  const [scheme, token] = authorization.split(' ');
+  return scheme?.toLowerCase() === 'bearer' ? token : '';
+};
+
+const requireInternalToken = (req, res, next) => {
+  const expectedToken = process.env.PPAI_INTERNAL_API_TOKEN;
+  const providedToken = req.get('x-internal-token') || getBearerToken(req.get('authorization'));
+
+  if (expectedToken && safeEqual(providedToken, expectedToken)) {
+    return next();
+  }
+
+  return res.unauthorized('Invalid internal token');
+};
+
+export default requireInternalToken;

+ 213 - 0
server/models/Games.bak.js

@@ -0,0 +1,213 @@
+import GetTranslation from "../libs/getTranslation.js";
+import { getSolutionsWithRelations, getGamesRelationsMap } from "../libs/getGamesRelations.js";
+import Store from "../state/store.js";
+
+import { getPlatformIorsDetailInfo, getSolutionByLatestIors, getSoulutionBetResult } from "./Markets.js";
+
+export const getLeagues = async () => {
+  const { polymarket, pinnacle } = Store.get('leagues') ?? { polymarket: [], pinnacle: [] };
+  const polymarketNames = polymarket.map(item => item.name);
+  const translatedNames = await GetTranslation(polymarketNames);
+  const newPolymarket = polymarket.map(item => {
+    const { name } = item;
+    const localesName = translatedNames[name] ?? name;
+    return { ...item, localesName };
+  });
+  return { polymarket: newPolymarket, pinnacle };
+}
+
+export const setLeaguesRelation = async (relation) => {
+  const { id, platforms } = relation;
+  if (!id || !platforms) {
+    return Promise.reject(new Error('invalid request', { cause: 400 }));
+  }
+  const storeRelations = Store.get('leaguesRelations') ?? {};
+  if (storeRelations[id]) {
+    return Promise.reject(new Error('relation already exists', { cause: 400 }));
+  }
+  storeRelations[id] = relation;
+  Store.set('leaguesRelations', storeRelations);
+  return Promise.resolve();
+};
+
+export const removeLeaguesRelation = async (id) => {
+  if (!id) {
+    return Promise.reject(new Error('invalid request', { cause: 400 }));
+  }
+  const storeRelations = Store.get('leaguesRelations') ?? {};
+  if (!storeRelations[id]) {
+    return Promise.reject(new Error('relation not found', { cause: 400 }));
+  }
+  delete storeRelations[id];
+  Store.set('leaguesRelations', storeRelations);
+  return Promise.resolve();
+};
+
+export const getLeaguesRelations = async () => {
+  const storeRelations = Object.values(Store.get('leaguesRelations') ?? {});
+  const polymarketNames = storeRelations.map(item => item.platforms.polymarket.name);
+  const translatedNames = await GetTranslation(polymarketNames);
+  const newRelations = storeRelations.map(item => {
+    const { platforms: { polymarket, pinnacle } } = item;
+    const { name } = polymarket;
+    const localesName = translatedNames[name] ?? name;
+    return { ...item, platforms: { polymarket: { ...polymarket, localesName }, pinnacle } };
+  });
+  return Promise.resolve(newRelations);
+};
+
+export const getGames = async () => {
+  const { polymarket, pinnacle } = Store.get('games') ?? { polymarket: [], pinnacle: [] };
+  const polymarketNames = [ ...new Set(polymarket.map(item => [item.teamHomeName, item.teamAwayName, item.leagueName]).flat()) ];
+  const translatedNames = await GetTranslation(polymarketNames);
+  const newPolymarket = polymarket.map(item => {
+    const { leagueName, teamHomeName, teamAwayName } = item;
+    const localesTeamHomeName = translatedNames[teamHomeName] ?? teamHomeName;
+    const localesTeamAwayName = translatedNames[teamAwayName] ?? teamAwayName;
+    const localesLeagueName = translatedNames[leagueName] ?? leagueName;
+    return { ...item, localesTeamHomeName, localesTeamAwayName, localesLeagueName };
+  }).filter(item => {
+    const { timestamp } = item;
+    const now = Date.now();
+    return (timestamp + 1000 * 60 * 60 * 2) > now;
+  }).sort((a, b) => a.timestamp - b.timestamp);
+
+  return { polymarket: newPolymarket, pinnacle };
+}
+
+export const setGamesRelation = async (relation) => {
+  const { id, platforms, timestamp } = relation;
+  if (!id || !platforms || !timestamp) {
+    return Promise.reject(new Error('invalid request', { cause: 400 }));
+  }
+  const storeRelations = Store.get('gamesRelations') ?? {};
+  if (storeRelations[id]) {
+    return Promise.reject(new Error('relation already exists', { cause: 400 }));
+  }
+  storeRelations[id] = relation;
+  Store.set('gamesRelations', storeRelations);
+  return Promise.resolve();
+}
+
+export const removeGamesRelation = async (id) => {
+  if (!id) {
+    return Promise.reject(new Error('invalid request', { cause: 400 }));
+  }
+  const storeRelations = Store.get('gamesRelations') ?? {};
+  if (!storeRelations[id]) {
+    return Promise.reject(new Error('relation not found', { cause: 400 }));
+  }
+  delete storeRelations[id];
+  Store.set('gamesRelations', storeRelations);
+  return Promise.resolve();
+}
+
+export const getGamesRelations = async () => {
+  // const storeData = Store.get('gamesRelations') ?? {};
+  // console.log('get games relations', storeData);
+  // const storeRelations = Object.values(storeData);
+  // console.log('store relations', storeRelations);
+  // const polymarketNames = [ ...new Set(storeRelations.map(item => {
+  //   const { teamHomeName, teamAwayName, leagueName } = item.platforms.polymarket;
+  //   return [teamHomeName, teamAwayName, leagueName];
+  // }).flat()) ];
+  // const translatedNames = await GetTranslation(polymarketNames);
+  // const newRelations = storeRelations.map(item => {
+  //   const { platforms: { polymarket, pinnacle } } = item;
+  //   const { teamHomeName, teamAwayName, leagueName } = polymarket;
+  //   const localesTeamHomeName = translatedNames[teamHomeName] ?? teamHomeName;
+  //   const localesTeamAwayName = translatedNames[teamAwayName] ?? teamAwayName;
+  //   const localesLeagueName = translatedNames[leagueName] ?? leagueName;
+  //   return { ...item, platforms: { polymarket: { ...polymarket, localesTeamHomeName, localesTeamAwayName, localesLeagueName }, pinnacle } };
+  // }).sort((a, b) => a.timestamp - b.timestamp);
+  // const newRelations = storeRelations.map(item => {
+  //   return { ...item };
+  // }).sort((a, b) => a.timestamp - b.timestamp);
+
+  const gamesRelations = Object.values(getGamesRelationsMap()).sort((a, b) => a.timestamp - b.timestamp);
+  return Promise.resolve(gamesRelations);
+}
+
+/**
+ * 获取解决方案
+ * @param {*} param0
+ * @returns
+ */
+export const getSolutions = async ({ min_profit_rate = 0 } = {}) => {
+  const solutions = Store.get('solutions') ?? [];
+  const solutionsList = solutions.filter(solution => {
+    const { sol: { win_profit_rate } } = solution;
+    return win_profit_rate >= min_profit_rate;
+  }).sort((a, b) => {
+    return b.sol.win_profit_rate - a.sol.win_profit_rate;
+  });
+  return getSolutionsWithRelations(solutionsList, 5);
+}
+
+/**
+ * 获取策略对应的盘口信息
+ */
+export const getSolutionIorsInfo = async (sid) => {
+  const solution = Store.get('solutions')?.find(item => item.sid == sid);
+  if (!solution) {
+    return Promise.reject(new Error('solution not found', { cause: 400 }));
+  }
+  const { info: { id }, cpr, sol: { cross_type } } = solution;
+  const gamesRelations = Store.get('gamesRelations') ?? {};
+  const gameRelation = gamesRelations[id];
+  if (!gameRelation) {
+    return Promise.reject(new Error('game relation not found', { cause: 400 }));
+  }
+  const { platforms: { polymarket, pinnacle } } = gameRelation;
+  const idMap = { polymarket: polymarket.id, pinnacle: pinnacle.id };
+  const iorsInfo = await Promise.all(cpr.map(item => {
+    const { k, p } = item;
+    return getPlatformIorsDetailInfo(k, p, idMap[p]);
+  }));
+
+  return { cpr, iorsInfo, cross_type };
+  // const accountBalance = await getAccountBalance();
+  // const solutionInfo = getSolutionByLatestIors(iorsInfo, cross_type);
+  // return { cpr, iorsInfo, ...solutionInfo };
+}
+
+/**
+ * 根据策略下注
+ */
+export const betSolution = async (sid, stake=0) => {
+  const solutionIorsInfo = await getSolutionIorsInfo(sid);
+  const { iorsInfo, cross_type } = solutionIorsInfo;
+  const solutionInfo = getSolutionByLatestIors(iorsInfo, cross_type);
+  return solutionInfo;
+  if (solutionInfo?.error) {
+    const error = new Error(solutionInfo.error, { cause: 400 });
+    error.data = solutionInfo.data;
+    return Promise.reject(error);
+  }
+  const betResult = await getSoulutionBetResult({ ...solutionIorsInfo, ...solutionInfo, stake });
+  return { betResult, ...solutionIorsInfo, ...solutionInfo };
+}
+
+/**
+ * 清理过期关系
+ */
+const cleanGamesRelations = () => {
+  const now = Date.now();
+  const storeRelations = Store.get('gamesRelations') ?? [];
+  Object.keys(storeRelations).forEach(key => {
+    const relation = storeRelations[key];
+    const { timestamp } = relation;
+    if ((timestamp + 1000 * 60 * 60 * 2) < now) {
+      delete storeRelations[key];
+    }
+  });
+  Store.set('gamesRelations', storeRelations);
+}
+
+setInterval(cleanGamesRelations, 1000 * 60);
+
+export default {
+  getLeagues, setLeaguesRelation, removeLeaguesRelation, getLeaguesRelations,
+  getGames, setGamesRelation, removeGamesRelation, getGamesRelations,
+  getSolutions, getSolutionIorsInfo, betSolution,
+};

+ 64 - 41
server/models/Games.js

@@ -2,7 +2,23 @@ import GetTranslation from "../libs/getTranslation.js";
 import { getSolutionsWithRelations, getGamesRelationsMap } from "../libs/getGamesRelations.js";
 import Store from "../state/store.js";
 
-import { getPlatformIorsDetailInfo, getSolutionByLatestIors, getSoulutionBetResult } from "./Markets.js";
+import {
+  getPlatformIorsDetailInfo,
+  getSolutionByLatestIors,
+  createPolymarketLimitBuyOrder,
+  getPolymarketBalanceAllowance,
+  transferPolymarketWallet,
+} from "./Markets.js";
+
+/**
+ * 精确浮点数字
+ * @param {number} number
+ * @param {number} x
+ * @returns {number}
+ */
+const fixFloat = (number, x=3) => {
+  return parseFloat(number.toFixed(x));
+}
 
 export const getLeagues = async () => {
   const { polymarket, pinnacle } = Store.get('leagues') ?? { polymarket: [], pinnacle: [] };
@@ -103,27 +119,6 @@ export const removeGamesRelation = async (id) => {
 }
 
 export const getGamesRelations = async () => {
-  // const storeData = Store.get('gamesRelations') ?? {};
-  // console.log('get games relations', storeData);
-  // const storeRelations = Object.values(storeData);
-  // console.log('store relations', storeRelations);
-  // const polymarketNames = [ ...new Set(storeRelations.map(item => {
-  //   const { teamHomeName, teamAwayName, leagueName } = item.platforms.polymarket;
-  //   return [teamHomeName, teamAwayName, leagueName];
-  // }).flat()) ];
-  // const translatedNames = await GetTranslation(polymarketNames);
-  // const newRelations = storeRelations.map(item => {
-  //   const { platforms: { polymarket, pinnacle } } = item;
-  //   const { teamHomeName, teamAwayName, leagueName } = polymarket;
-  //   const localesTeamHomeName = translatedNames[teamHomeName] ?? teamHomeName;
-  //   const localesTeamAwayName = translatedNames[teamAwayName] ?? teamAwayName;
-  //   const localesLeagueName = translatedNames[leagueName] ?? leagueName;
-  //   return { ...item, platforms: { polymarket: { ...polymarket, localesTeamHomeName, localesTeamAwayName, localesLeagueName }, pinnacle } };
-  // }).sort((a, b) => a.timestamp - b.timestamp);
-  // const newRelations = storeRelations.map(item => {
-  //   return { ...item };
-  // }).sort((a, b) => a.timestamp - b.timestamp);
-
   const gamesRelations = Object.values(getGamesRelationsMap()).sort((a, b) => a.timestamp - b.timestamp);
   return Promise.resolve(gamesRelations);
 }
@@ -144,17 +139,29 @@ export const getSolutions = async ({ min_profit_rate = 0 } = {}) => {
   return getSolutionsWithRelations(solutionsList, 5);
 }
 
+/**
+ * 计算Polymarket下注数量精度
+ */
+const calculatePolymarketStakeCount = ({ stake, tick_size, bid_ex, min_order_size } = {}) => {
+  const decimalPlaces = tick_size.toString().split('.')[1]?.length ?? 0;
+  const stakeCount = fixFloat(stake / bid_ex, decimalPlaces);
+  if (stakeCount < min_order_size) {
+    return null;
+  }
+  return stakeCount;
+}
+
 /**
  * 获取策略对应的盘口信息
  */
 export const getSolutionIorsInfo = async (sid) => {
+  const gamesRelations = getGamesRelationsMap();
   const solution = Store.get('solutions')?.find(item => item.sid == sid);
   if (!solution) {
     return Promise.reject(new Error('solution not found', { cause: 400 }));
   }
-  const { info: { id }, cpr, sol: { cross_type } } = solution;
-  const gamesRelations = Store.get('gamesRelations') ?? {};
-  const gameRelation = gamesRelations[id];
+  const { rid, cpr, sol: { cross_type } } = solution;
+  const gameRelation = gamesRelations[rid];
   if (!gameRelation) {
     return Promise.reject(new Error('game relation not found', { cause: 400 }));
   }
@@ -164,28 +171,44 @@ export const getSolutionIorsInfo = async (sid) => {
     const { k, p } = item;
     return getPlatformIorsDetailInfo(k, p, idMap[p]);
   }));
-
-  return { cpr, iorsInfo, cross_type };
-  // const accountBalance = await getAccountBalance();
-  // const solutionInfo = getSolutionByLatestIors(iorsInfo, cross_type);
-  // return { cpr, iorsInfo, ...solutionInfo };
+  return { cpr, iorsInfo, cross_type, gameRelation };
 }
 
 /**
  * 根据策略下注
  */
 export const betSolution = async (sid, stake=0) => {
-  const solutionIorsInfo = await getSolutionIorsInfo(sid);
-  const { iorsInfo, cross_type } = solutionIorsInfo;
+  const { cpr, iorsInfo, cross_type } = await getSolutionIorsInfo(sid);
   const solutionInfo = getSolutionByLatestIors(iorsInfo, cross_type);
-  return solutionInfo;
-  if (solutionInfo?.error) {
-    const error = new Error(solutionInfo.error, { cause: 400 });
-    error.data = solutionInfo.data;
-    return Promise.reject(error);
+  const { stakeLimit: { minGroup } } = solutionInfo ?? { stakeLimit: {} };
+  if (!minGroup?.length) {
+    return Promise.reject(new Error('no stake limit', { cause: 400 }));
+  }
+  const polymarketIndex = cpr.findIndex(item => item.p === 'polymarket');
+  const polymarketInfo = iorsInfo[polymarketIndex];
+  if (!polymarketInfo) {
+    return Promise.reject(new Error('polymarket info not found', { cause: 400 }));
+  }
+  const pinnacleInfos = [...iorsInfo];
+  pinnacleInfos.splice(polymarketIndex, 1);
+
+  const polymarketStake = minGroup[polymarketIndex];
+  const polymarketPrice = cpr[polymarketIndex].bid_ex;
+  const polymarketStakeCount = calculatePolymarketStakeCount({ stake: polymarketStake, tick_size: polymarketInfo.tick_size, bid_ex: polymarketPrice, min_order_size: polymarketInfo.min_order_size });
+  if (!polymarketStakeCount) {
+    return Promise.reject(new Error('polymarket stake count not found', { cause: 400 }));
   }
-  const betResult = await getSoulutionBetResult({ ...solutionIorsInfo, ...solutionInfo, stake });
-  return { betResult, ...solutionIorsInfo, ...solutionInfo };
+
+  // return { cpr, solutionInfo, polymarketInfo, pinnacleInfos, polymarketStakeCount};
+  const polymarketOrder = await createPolymarketLimitBuyOrder({
+    tokenID: polymarketInfo.asset_id,
+    price: polymarketPrice,
+    size: polymarketStakeCount,
+    tickSize: polymarketInfo.tick_size,
+    negRisk: polymarketInfo.neg_risk,
+  });
+
+  return { polymarketOrder, ...solutionInfo, iorsInfo, cpr };
 }
 
 /**
@@ -209,5 +232,5 @@ setInterval(cleanGamesRelations, 1000 * 60);
 export default {
   getLeagues, setLeaguesRelation, removeLeaguesRelation, getLeaguesRelations,
   getGames, setGamesRelation, removeGamesRelation, getGamesRelations,
-  getSolutions, getSolutionIorsInfo, betSolution,
-};
+  getSolutions, getSolutionIorsInfo, betSolution, getPolymarketBalanceAllowance, transferPolymarketWallet,
+};

+ 458 - 0
server/models/Markets.bak.js

@@ -0,0 +1,458 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { getData } from "../libs/cache.js";
+import Logs from "../libs/logs.js";
+
+import eventSolutions from '../triangle/eventSolutions.js';
+
+import { getOrderBook, placeOrder as polymarketPlaceOrder } from "../../polymarket/libs/polymarketClient.js";
+import { getLineInfo, placeOrder as pinnaclePlaceOrder } from "../../polymarket/libs/pinnacleClient.js";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const polymarketMarketsCacheFile = path.join(__dirname, "../../polymarket/cache/polymarketMarketsCache.json");
+const pinnacleGamesCacheFile = path.join(__dirname, "../../pinnacle/cache/pinnacleGamesCache.json");
+
+/**
+ * 最小盈利率
+ */
+const MIN_PROFIT_RATE = -1;
+
+/**
+ * 精确浮点数字
+ * @param {number} number
+ * @param {number} x
+ * @returns {number}
+ */
+const fixFloat = (number, x=3) => {
+  return parseFloat(number.toFixed(x));
+}
+
+/**
+ * 依次执行任务
+ */
+const runSequentially = async (tasks) => {
+  return new Promise(async (resolve, reject) => {
+    const results = [];
+    let isError = false;
+    for (const task of tasks) {
+      // task 必须是一个「返回 Promise 的函数」
+      const res = await task().catch(err => {
+        err.results = results;
+        isError = true;
+        reject(err);
+      });
+      if (isError) {
+        break;
+      }
+      else if (res) {
+        results.push(res);
+      }
+    }
+    if (isError) {
+      return;
+    }
+    resolve(results);
+  })
+}
+
+/**
+ * 计算符合比例的最大和最小数组
+ */
+const findMaxMinGroup = (ratios, minVals, maxVals) => {
+  const n = ratios.length;
+
+  // 1. 计算比例总和 & 归一化比例
+  const totalRatio = ratios.reduce((a, b) => a + b, 0);
+  const proportions = ratios.map(r => r / totalRatio);
+
+  // 2. 计算每个位置允许的最大/最小倍数
+  let maxPossibleScale = Infinity;
+  let minPossibleScale = 0;   // 如果允许0,通常从0开始
+
+  for (let i = 0; i < n; i++) {
+    // 上限约束
+    if (proportions[i] > 0) {
+      maxPossibleScale = Math.min(maxPossibleScale, maxVals[i] / proportions[i]);
+    }
+    // 下限约束(如果 minVals[i] > 0 才有意义)
+    if (proportions[i] > 0 && minVals[i] > 0) {
+      minPossibleScale = Math.max(minPossibleScale, minVals[i] / proportions[i]);
+    }
+  }
+
+  // 3. 最终取值
+  const maxGroup = proportions.map(p => p * maxPossibleScale);
+  const minGroup = proportions.map(p => p * Math.max(minPossibleScale, 0));
+
+  return {
+    maxGroup: maxGroup.map(v => fixFloat(v)), // 可控制精度
+    minGroup: minGroup.map(v => fixFloat(v)),
+    proportions: proportions.map(v => fixFloat(v)),
+    scaleForMax: fixFloat(maxPossibleScale),
+    scaleForMin: fixFloat(minPossibleScale)
+  };
+}
+
+/**
+ * 解析盘口信息
+ */
+const parseRatio = (ratioString) => {
+  if (!ratioString) {
+    return null;
+  }
+  return parseFloat(`${ratioString[0]}.${ratioString.slice(1)}`);
+}
+
+const parseIor = (ior) => {
+  const iorMatch = ior.match(/ior_(m|r|ou|wm|ot)([ao])?([hcn])?_?(\d+)?/);
+  if (!iorMatch) {
+    return null;
+  }
+  const [, type, action, side, ratio] = iorMatch;
+  return { type, action, side, ratio };
+}
+
+const getPolymarketIorInfo = async (ior, id) => {
+  const cacheData = await getData(polymarketMarketsCacheFile);
+  const marketsData = cacheData[id]?.marketsData;
+  if (!marketsData) {
+    Logs.outDev('polymarket markets data not found', id);
+    return null;
+  }
+  const iorOptions = parseIor(ior);
+  if (!iorOptions) {
+    Logs.outDev('polymarket ior options not found', ior);
+    return null;
+  }
+  const { type, action, side, ratio } = iorOptions;
+
+  let marketTypeData, outcomesSide;
+
+  if (type === 'm' && !ratio) {
+    const sideKey = side === 'h' ? 'Home' : side === 'c' ? 'Away' : 'Draw';
+    const sideAction = action === 'o' ? 'No' : 'Yes';
+    marketTypeData = marketsData.moneyline[sideKey];
+    outcomesSide = sideAction;
+  }
+
+  else if (type === 'r') {
+    const sideKey = side === 'h' ? 'Home' : side === 'c' ? 'Away' : '';
+    let ratioDirection = 1;
+    if (side === 'c' && action === 'a' || side === 'h' && !action) {
+      ratioDirection = -1;
+    }
+    const ratioValue = parseRatio(ratio) * ratioDirection;
+    const ratioKey = ratioValue > 0 ? `+${ratioValue}` : `${ratioValue}`;
+    marketTypeData = marketsData.spreads?.[ratioKey];
+    outcomesSide = sideKey;
+  }
+
+  else if (type === 'ou') {
+    const sideKey = side === 'c' ? 'Over' : side === 'h' ? 'Under' : '';
+    const ratioKey = parseRatio(ratio);
+    marketTypeData = marketsData.totals[ratioKey];
+    outcomesSide = sideKey;
+  }
+
+  const result = marketTypeData?.outcomes?.[outcomesSide];
+
+  if (!result) {
+    Logs.outDev('polymarket market type data not found', { ior, id, type, action, side, ratio, marketTypeData, outcomesSide });
+    return null;
+  }
+  return result;
+}
+
+const getPinnacleIorInfo = async (ior, id) => {
+  const cacheData = await getData(pinnacleGamesCacheFile);
+  const gamesData = cacheData[id];
+  if (!gamesData) {
+    Logs.outDev('pinnacle games data not found', id);
+    return null;
+  }
+  const iorOptions = parseIor(ior);
+  if (!iorOptions) {
+    Logs.outDev('pinnacle ior options not found', ior);
+    return null;
+  }
+  const { type, action, side, ratio } = iorOptions;
+
+  const { leagueId, id: eventId, home: homeTeamName, away: awayTeamName, periods={}, specials={}} = gamesData;
+
+  const straightData = periods.straight ?? {};
+  const { lineId: straightLineId, moneyline, spreads, totals } = straightData;
+  const { winningMargin, exactTotalGoals } = specials;
+
+  if (type === 'm' && moneyline && !action && !ratio) {
+    const sideKey = side === 'h' ? 'home' : side === 'c' ? 'away' : 'draw';
+    const team = side === 'h' ? 'TEAM1' : side === 'c' ? 'TEAM2' : 'DRAW';
+    const odds = moneyline[sideKey];
+    return { leagueId, eventId, betType: 'MONEYLINE', team, lineId: straightLineId, odds };
+  }
+
+  else if (type === 'r' && spreads) {
+    let ratioDirection = 1;
+    if (side === 'c' && action === 'a' || side === 'h' && !action) {
+      ratioDirection = -1;
+    }
+    const ratioKey = parseRatio(ratio) * ratioDirection;
+    const itemSpread = spreads.find(spread => spread.hdp == ratioKey);
+    if (!itemSpread) {
+      Logs.outDev('pinnacle item spread not found', id, type, action, side, ratio);
+      return null;
+    }
+    const { altLineId=null, home, away } = itemSpread;
+    const odds = side === 'h' ? home : away;
+    const team = side === 'h' ? 'TEAM1' : 'TEAM2';
+    const handicap = ratioKey * (side === 'h' ? 1 : -1);
+    return { leagueId, eventId, handicap, betType: 'SPREAD', team, lineId: straightLineId, altLineId, odds };
+  }
+
+  else if (type === 'ou' && totals) {
+    const ratioKey = parseRatio(ratio);
+    const itemTotal = totals.find(total => total.points == ratioKey);
+    if (!itemTotal) {
+      Logs.outDev('pinnacle item total not found', id, type, action, side, ratio);
+      return null;
+    }
+    const { altLineId=null, over, under } = itemTotal;
+    const odds = side === 'c' ? over : under;
+    const sideKey = side === 'c' ? 'OVER' : 'UNDER';
+    return { leagueId, eventId, handicap: ratioKey, betType: 'TOTAL_POINTS', side: sideKey, lineId: straightLineId, altLineId, odds };
+  }
+
+  else if (type === 'wm' && winningMargin) {
+    const ratioKey = parseRatio(ratio);
+    const { id: specialId } = winningMargin;
+    const wmName = side === 'h' ? `${homeTeamName} By ${ratioKey}` : side === 'c' ? `${awayTeamName} By ${ratioKey}` : '';
+    const wmItem = winningMargin.contestants.find(contestant => contestant.name == wmName);
+    if (!wmItem) {
+      Logs.outDev('pinnacle item winning margin not found', id, type, action, side, ratio);
+      return null;
+    }
+    const { id: contestantId, lineId, price } = wmItem;
+    return { leagueId, eventId, specialId, contestantId, lineId, odds: price };
+  }
+
+  else if (type === 'ot' && exactTotalGoals) {
+    const ratioKey = parseRatio(ratio);
+    const { id: specialId } = exactTotalGoals;
+    const otItem = exactTotalGoals.contestants.find(contestant => contestant.name == ratioKey);
+    if (!otItem) {
+      Logs.outDev('pinnacle item exact total goals not found', id, type, action, side, ratio);
+      return null;
+    }
+    const { id: contestantId, lineId, price } = otItem;
+    return { leagueId, eventId, specialId, contestantId, lineId, odds: price };
+  }
+
+  else {
+    Logs.outDev('pinnacle ior type not found', ior, id);
+    return null;
+  }
+}
+
+/**
+ * 获取平台盘口id信息
+ */
+export const getPlatformIorInfo = async (ior, platform, id) => {
+  const getInfo = {
+    polymarket() {
+      return getPolymarketIorInfo(ior, id);
+    },
+    pinnacle() {
+      return getPinnacleIorInfo(ior, id);
+    }
+  }
+  Logs.outDev('getPlatformIorInfo', { ior, platform, id });
+  return getInfo[platform]?.();
+}
+
+
+/**
+ * 获取polymarket盘口详细信息
+ */
+const getPolymarketIorDetailInfo = async (info) => {
+  const { id } = info;
+  return getOrderBook(id);
+}
+
+/**
+ * 获取pinnacle盘口详细信息
+ */
+const getPinnacleIorDetailInfo = async (info, channel) => {
+  return getLineInfo(info, channel);
+}
+
+/**
+ * 获取平台盘口详细信息
+ */
+export const getPlatformIorsDetailInfo = async (ior, platform, id, channel) => {
+  const info = await getPlatformIorInfo(ior, platform, id);
+  if (!info) {
+    return Promise.reject(new Error('platform ior info not found', { cause: 400 }));
+  }
+  const getInfo = {
+    polymarket() {
+      return getPolymarketIorDetailInfo(info);
+    },
+    pinnacle() {
+      return getPinnacleIorDetailInfo(info, channel);
+    }
+  }
+  return getInfo[platform]?.();
+}
+
+/**
+ * 平台盘口下注
+ */
+export const placePlatformOrder = async (ior, platform, id, stake=0, channel) => {
+  const iorInfo = await getPlatformIorsDetailInfo(ior, platform, id, channel);
+  const betInfo = { ...iorInfo, stakeSize: stake };
+  const placeOrder = {
+    polymarket() {
+      return polymarketPlaceOrder(betInfo);
+    },
+    pinnacle() {
+      return pinnaclePlaceOrder(betInfo, channel);
+    }
+  }
+  return placeOrder[platform]?.();
+}
+
+/**
+ * 根据最新赔率获取策略
+ */
+export const getSolutionByLatestIors = (iorsInfo, cross_type, retry=false) => {
+  const askIndex = +retry;
+  const iorsValues = iorsInfo.map(item => {
+    if (item.asks) {
+      const bestAsk = [...item.asks].sort((a, b) => a.price - b.price)[askIndex];
+      const value = fixFloat(1 / bestAsk.price, 3);
+      const maxStake = fixFloat(bestAsk.size * bestAsk.price);
+      const minStake = fixFloat(item.min_order_size * bestAsk.price);
+      return { value, maxStake, minStake, bestPrice: bestAsk.price };
+    }
+    else if (item.info) {
+      const value = item.info.price;
+      const maxStake = Math.floor(item.info.maxRiskStake);
+      const minStake = Math.ceil(item.info.minRiskStake*10);
+      return { value, maxStake, minStake };
+    }
+  });
+
+  const nullIndex = iorsValues.findIndex(item => item.value == null);
+
+  if (nullIndex >= 0) {
+    return { error: `IORS_NULL_VALUE_AT_INDEX_${nullIndex}_RETRY_${askIndex}`, data: iorsInfo };
+  }
+
+  const baseIndex = iorsValues.reduce((minIdx, cur, idx) => cur.value < iorsValues[minIdx].value ? idx : minIdx, 0);
+
+  if (iorsValues.length === 2) {
+    iorsValues.push({ value: 1, maxStake: 0, minStake: 0 });
+  }
+
+  const betInfo = {
+    cross_type,
+    base_index: baseIndex,
+    base_stake: 10000,
+    odds_side_a: fixFloat(iorsValues[0].value - 1),
+    odds_side_b: fixFloat(iorsValues[1].value - 1),
+    odds_side_c: fixFloat(iorsValues[2].value - 1),
+  };
+
+  const sol = eventSolutions(betInfo, true);
+  const { win_average, win_profit_rate, gold_side_a, gold_side_b, gold_side_c } = sol;
+
+  if (win_profit_rate < MIN_PROFIT_RATE) {
+    Logs.outDev('win_profit_rate is less than profit rate limit', sol, iorsValues, iorsInfo, cross_type);
+    return { error: `WIN_PROFIT_RATE_LESS_THAN_MIN_PROFIT_RATE_RETRY_${askIndex}`, data: { sol, iorsValues, iorsInfo } };
+  }
+
+  const goldRatios = [gold_side_a, gold_side_b];
+  if (gold_side_c) {
+    goldRatios.push(gold_side_c);
+  }
+  const minVals = iorsValues.map(item => item.minStake);
+  const maxVals = iorsValues.map(item => item.maxStake);
+
+  const stakeLimit = findMaxMinGroup(goldRatios, minVals, maxVals);
+  const { scaleForMax, scaleForMin } = stakeLimit;
+
+  if (scaleForMax < scaleForMin) {
+    Logs.outDev('scaleForMax is less than scaleForMin');
+    if (!retry) {
+      return getSolutionByLatestIors(iorsInfo, cross_type, true);
+    }
+    else {
+      return { error: `NO_ENOUGH_STAKE_SIZE_TO_BET_RETRY_${askIndex}`, data: { sol, iorsValues, iorsInfo } };
+    }
+  }
+
+  const winLimit = {
+    max: fixFloat(win_average * scaleForMax / (gold_side_a + gold_side_b + gold_side_c)),
+    min: fixFloat(win_average * scaleForMin / (gold_side_a + gold_side_b + gold_side_c)),
+  }
+
+  return { sol, iors: iorsValues, stakeLimit, winLimit };
+}
+
+export const getSoulutionBetResult = async ({ iors, iorsInfo, stakeLimit, stake=0 }) => {
+  const maxStake = stakeLimit.maxGroup.reduce((acc, curr) => acc + curr, 0);
+  const minStake = stakeLimit.minGroup.reduce((acc, curr) => acc + curr, 0);
+  let betStakeGroup = [];
+  if (stake > maxStake || stake < 0) {
+    stake = maxStake;
+    betStakeGroup = stakeLimit.maxGroup;
+  }
+  else if (stake < minStake) {
+    stake = minStake;
+    betStakeGroup = stakeLimit.minGroup;
+  }
+  else {
+    betStakeGroup = stakeLimit.proportions.map(p => p * stake);
+  }
+
+  const betInfo = iorsInfo.map((item, index) => {
+    if (item.asks) {
+      const bestPrice = +iors[index].bestPrice;
+      const stakeSize = fixFloat(betStakeGroup[index] / bestPrice, 0); // 必须保证买单金额小数不超过2位
+      return { ...item, stakeSize, bestPrice, betIndex: index, platform: 'polymarket' }
+    }
+    else if (item.info) {
+      const stakeSize = fixFloat(betStakeGroup[index], 0);
+      return { ...item, stakeSize, betIndex: index, platform: 'pinnacle' }
+    }
+  }).sort((a, b) => {
+    if (a.platform === 'polymarket' && b.platform === 'pinnacle') {
+      return -1;
+    }
+    else if (a.platform === 'pinnacle' && b.platform === 'polymarket') {
+      return 1;
+    }
+    else {
+      return 0;
+    }
+  });
+
+  // return { betInfo };
+  return runSequentially(betInfo.map(item => async() => {
+    if (item.asks) {
+      const result = await polymarketPlaceOrder(item);
+      return [result, item.betIndex]
+    }
+    else if (item.info) {
+      const result = await pinnaclePlaceOrder(item);
+      return [result, item.betIndex]
+    }
+  })).then(results => {
+    return results.sort((a, b) => a[1] - b[1]).map(item => item[0]);
+  }).catch(error => {
+    Logs.errDev(error);
+    return Promise.reject(error);
+  });
+}

+ 133 - 272
server/models/Markets.js

@@ -1,24 +1,12 @@
-import path from "path";
-import { fileURLToPath } from "url";
-
-import { getData } from "../libs/cache.js";
 import Logs from "../libs/logs.js";
 
+import { platformGet, platformPost } from "../libs/platformRequest.js";
 import eventSolutions from '../triangle/eventSolutions.js';
 
-import { getOrderBook, placeOrder as polymarketPlaceOrder } from "../../polymarket/libs/polymarketClient.js";
-import { getLineInfo, placeOrder as pinnaclePlaceOrder } from "../../pinnacle/libs/pinnacleClient.js";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-const polymarketMarketsCacheFile = path.join(__dirname, "../../polymarket/cache/polymarketMarketsCache.json");
-const pinnacleGamesCacheFile = path.join(__dirname, "../../pinnacle/cache/pinnacleGamesCache.json");
-
-/**
- * 最小盈利率
- */
+const MAKER_FEE_RATE = 0.03;
+const MAKER_REBATE_RATE = 0.25;
 const MIN_PROFIT_RATE = -1;
+const PINNACLE_MIN_RISK_STAKE = 1;
 
 /**
  * 精确浮点数字
@@ -30,38 +18,10 @@ const fixFloat = (number, x=3) => {
   return parseFloat(number.toFixed(x));
 }
 
-/**
- * 依次执行任务
- */
-const runSequentially = async (tasks) => {
-  return new Promise(async (resolve, reject) => {
-    const results = [];
-    let isError = false;
-    for (const task of tasks) {
-      // task 必须是一个「返回 Promise 的函数」
-      const res = await task().catch(err => {
-        err.results = results;
-        isError = true;
-        reject(err);
-      });
-      if (isError) {
-        break;
-      }
-      else if (res) {
-        results.push(res);
-      }
-    }
-    if (isError) {
-      return;
-    }
-    resolve(results);
-  })
-}
-
 /**
  * 计算符合比例的最大和最小数组
  */
-const findMaxMinGroup = (ratios, minVals, maxVals) => {
+const findMaxMinGroup = (ratios, minVals=10, maxVals=1000) => {
   const n = ratios.length;
 
   // 1. 计算比例总和 & 归一化比例
@@ -97,162 +57,53 @@ const findMaxMinGroup = (ratios, minVals, maxVals) => {
 }
 
 /**
- * 解析盘口信息
+ * 解析吃单手续费比
+ * 固定金额吃卖单时,手续费比约等于 费率*(1-价格)
+ * @param {*} price
+ * @returns {number}
  */
-const parseRatio = (ratioString) => {
-  if (!ratioString) {
-    return null;
-  }
-  return parseFloat(`${ratioString[0]}.${ratioString.slice(1)}`);
+const parseAskFee = (price) => {
+  return fixFloat(100 * MAKER_FEE_RATE * (1 - price), 4);
 }
 
-const parseIor = (ior) => {
-  const iorMatch = ior.match(/ior_(m|r|ou|wm|ot)([ao])?([hcn])?_?(\d+)?/);
-  if (!iorMatch) {
-    return null;
-  }
-  const [, type, action, side, ratio] = iorMatch;
-  return { type, action, side, ratio };
+/**
+ * 解析挂单返佣比
+ * 固定金额挂买单时,返佣比约等于 费率*(1-价格)*返佣比例
+ * @param {*} price
+ * @returns {number}
+ */
+const parseBidRebate = (price) => {
+  return fixFloat(100 * MAKER_FEE_RATE * (1 - price) * MAKER_REBATE_RATE, 4);
 }
 
+/**
+ * 获取polymarket盘口信息
+ * @param {*} ior
+ * @param {*} id
+ * @returns
+ */
 const getPolymarketIorInfo = async (ior, id) => {
-  const cacheData = await getData(polymarketMarketsCacheFile);
-  const marketsData = cacheData[id]?.marketsData;
-  if (!marketsData) {
-    Logs.outDev('polymarket markets data not found', id);
-    return null;
-  }
-  const iorOptions = parseIor(ior);
-  if (!iorOptions) {
-    Logs.outDev('polymarket ior options not found', ior);
-    return null;
-  }
-  const { type, action, side, ratio } = iorOptions;
-
-  let marketTypeData, outcomesSide;
-
-  if (type === 'm' && !ratio) {
-    const sideKey = side === 'h' ? 'Home' : side === 'c' ? 'Away' : 'Draw';
-    const sideAction = action === 'o' ? 'No' : 'Yes';
-    marketTypeData = marketsData.moneyline[sideKey];
-    outcomesSide = sideAction;
-  }
-
-  else if (type === 'r') {
-    const sideKey = side === 'h' ? 'Home' : side === 'c' ? 'Away' : '';
-    let ratioDirection = 1;
-    if (side === 'c' && action === 'a' || side === 'h' && !action) {
-      ratioDirection = -1;
-    }
-    const ratioValue = parseRatio(ratio) * ratioDirection;
-    const ratioKey = ratioValue > 0 ? `+${ratioValue}` : `${ratioValue}`;
-    marketTypeData = marketsData.spreads?.[ratioKey];
-    outcomesSide = sideKey;
-  }
-
-  else if (type === 'ou') {
-    const sideKey = side === 'c' ? 'Over' : side === 'h' ? 'Under' : '';
-    const ratioKey = parseRatio(ratio);
-    marketTypeData = marketsData.totals[ratioKey];
-    outcomesSide = sideKey;
-  }
-
-  const result = marketTypeData?.outcomes?.[outcomesSide];
-
-  if (!result) {
-    Logs.outDev('polymarket market type data not found', { ior, id, type, action, side, ratio, marketTypeData, outcomesSide });
-    return null;
-  }
-  return result;
+  return platformGet(`http://127.0.0.1:9021/api/trading/get_ior_info/${id}/${ior}`)
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('get polymarket ior info error', err);
+    return Promise.reject(err);
+  });
 }
 
+/**
+ * 获取pinnacle盘口信息
+ * @param {*} ior
+ * @param {*} id
+ * @returns
+ */
 const getPinnacleIorInfo = async (ior, id) => {
-  const cacheData = await getData(pinnacleGamesCacheFile);
-  const gamesData = cacheData[id];
-  if (!gamesData) {
-    Logs.outDev('pinnacle games data not found', id);
-    return null;
-  }
-  const iorOptions = parseIor(ior);
-  if (!iorOptions) {
-    Logs.outDev('pinnacle ior options not found', ior);
-    return null;
-  }
-  const { type, action, side, ratio } = iorOptions;
-
-  const { leagueId, id: eventId, home: homeTeamName, away: awayTeamName, periods={}, specials={}} = gamesData;
-
-  const straightData = periods.straight ?? {};
-  const { lineId: straightLineId, moneyline, spreads, totals } = straightData;
-  const { winningMargin, exactTotalGoals } = specials;
-
-  if (type === 'm' && moneyline && !action && !ratio) {
-    const sideKey = side === 'h' ? 'home' : side === 'c' ? 'away' : 'draw';
-    const team = side === 'h' ? 'TEAM1' : side === 'c' ? 'TEAM2' : 'DRAW';
-    const odds = moneyline[sideKey];
-    return { leagueId, eventId, betType: 'MONEYLINE', team, lineId: straightLineId, odds };
-  }
-
-  else if (type === 'r' && spreads) {
-    let ratioDirection = 1;
-    if (side === 'c' && action === 'a' || side === 'h' && !action) {
-      ratioDirection = -1;
-    }
-    const ratioKey = parseRatio(ratio) * ratioDirection;
-    const itemSpread = spreads.find(spread => spread.hdp == ratioKey);
-    if (!itemSpread) {
-      Logs.outDev('pinnacle item spread not found', id, type, action, side, ratio);
-      return null;
-    }
-    const { altLineId=null, home, away } = itemSpread;
-    const odds = side === 'h' ? home : away;
-    const team = side === 'h' ? 'TEAM1' : 'TEAM2';
-    const handicap = ratioKey * (side === 'h' ? 1 : -1);
-    return { leagueId, eventId, handicap, betType: 'SPREAD', team, lineId: straightLineId, altLineId, odds };
-  }
-
-  else if (type === 'ou' && totals) {
-    const ratioKey = parseRatio(ratio);
-    const itemTotal = totals.find(total => total.points == ratioKey);
-    if (!itemTotal) {
-      Logs.outDev('pinnacle item total not found', id, type, action, side, ratio);
-      return null;
-    }
-    const { altLineId=null, over, under } = itemTotal;
-    const odds = side === 'c' ? over : under;
-    const sideKey = side === 'c' ? 'OVER' : 'UNDER';
-    return { leagueId, eventId, handicap: ratioKey, betType: 'TOTAL_POINTS', side: sideKey, lineId: straightLineId, altLineId, odds };
-  }
-
-  else if (type === 'wm' && winningMargin) {
-    const ratioKey = parseRatio(ratio);
-    const { id: specialId } = winningMargin;
-    const wmName = side === 'h' ? `${homeTeamName} By ${ratioKey}` : side === 'c' ? `${awayTeamName} By ${ratioKey}` : '';
-    const wmItem = winningMargin.contestants.find(contestant => contestant.name == wmName);
-    if (!wmItem) {
-      Logs.outDev('pinnacle item winning margin not found', id, type, action, side, ratio);
-      return null;
-    }
-    const { id: contestantId, lineId, price } = wmItem;
-    return { leagueId, eventId, specialId, contestantId, lineId, odds: price };
-  }
-
-  else if (type === 'ot' && exactTotalGoals) {
-    const ratioKey = parseRatio(ratio);
-    const { id: specialId } = exactTotalGoals;
-    const otItem = exactTotalGoals.contestants.find(contestant => contestant.name == ratioKey);
-    if (!otItem) {
-      Logs.outDev('pinnacle item exact total goals not found', id, type, action, side, ratio);
-      return null;
-    }
-    const { id: contestantId, lineId, price } = otItem;
-    return { leagueId, eventId, specialId, contestantId, lineId, odds: price };
-  }
-
-  else {
-    Logs.outDev('pinnacle ior type not found', ior, id);
-    return null;
-  }
+  return platformGet(`https://cb.long.bid/api/trading/get_ior_info/${id}/${ior}`)
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('get pinnacle ior info error', err);
+    return Promise.reject(err);
+  });
 }
 
 /**
@@ -267,30 +118,42 @@ export const getPlatformIorInfo = async (ior, platform, id) => {
       return getPinnacleIorInfo(ior, id);
     }
   }
-  Logs.outDev('getPlatformIorInfo', { ior, platform, id });
-  return getInfo[platform]?.();
+  const result = await getInfo[platform]?.();
+  Logs.outDev('getPlatformIorInfo', { ior, platform, id },result);
+  return result;
 }
 
-
 /**
  * 获取polymarket盘口详细信息
  */
 const getPolymarketIorDetailInfo = async (info) => {
+  Logs.outDev('get polymarket ior detail info', info);
   const { id } = info;
-  return getOrderBook(id);
+  return platformGet(`http://127.0.0.1:9021/api/trading/orderbook/${id}`)
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('get polymarket ior detail info error', err);
+    return Promise.reject(err);
+  });
 }
 
 /**
  * 获取pinnacle盘口详细信息
  */
 const getPinnacleIorDetailInfo = async (info, channel) => {
-  return getLineInfo(info, channel);
+  Logs.outDev('get pinnacle ior detail info', { info, channel });
+  return platformPost(`http://127.0.0.1:9021/api/trading/get_line_info`, { info, channel })
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('get pinnacle ior detail info error', err);
+    return Promise.reject(err);
+  });
 }
 
 /**
  * 获取平台盘口详细信息
  */
-export const getPlatformIorsDetailInfo = async (ior, platform, id, channel) => {
+export const getPlatformIorsDetailInfo = async (ior, platform, id) => {
   const info = await getPlatformIorInfo(ior, platform, id);
   if (!info) {
     return Promise.reject(new Error('platform ior info not found', { cause: 400 }));
@@ -300,29 +163,12 @@ export const getPlatformIorsDetailInfo = async (ior, platform, id, channel) => {
       return getPolymarketIorDetailInfo(info);
     },
     pinnacle() {
-      return getPinnacleIorDetailInfo(info, channel);
+      return getPinnacleIorDetailInfo(info);
     }
   }
   return getInfo[platform]?.();
 }
 
-/**
- * 平台盘口下注
- */
-export const placePlatformOrder = async (ior, platform, id, stake=0, channel) => {
-  const iorInfo = await getPlatformIorsDetailInfo(ior, platform, id, channel);
-  const betInfo = { ...iorInfo, stakeSize: stake };
-  const placeOrder = {
-    polymarket() {
-      return polymarketPlaceOrder(betInfo);
-    },
-    pinnacle() {
-      return pinnaclePlaceOrder(betInfo, channel);
-    }
-  }
-  return placeOrder[platform]?.();
-}
-
 /**
  * 根据最新赔率获取策略
  */
@@ -331,16 +177,22 @@ export const getSolutionByLatestIors = (iorsInfo, cross_type, retry=false) => {
   const iorsValues = iorsInfo.map(item => {
     if (item.asks) {
       const bestAsk = [...item.asks].sort((a, b) => a.price - b.price)[askIndex];
-      const value = fixFloat(1 / bestAsk.price, 3);
-      const maxStake = fixFloat(bestAsk.size * bestAsk.price);
+      const bestPrice = bestAsk.price - item.tick_size;
+      const value = fixFloat(1 / bestPrice, 3);
+      const maxStake = 99999;
       const minStake = fixFloat(item.min_order_size * bestAsk.price);
-      return { value, maxStake, minStake, bestPrice: bestAsk.price };
+      const tickSize = +item.tick_size;
+      const negRisk = item.neg_risk;
+      const rebate = parseBidRebate(bestPrice);
+      const rebateType = 1;
+      return { value, maxStake, minStake, bestPrice, tickSize, negRisk, rebate, rebateType };
     }
     else if (item.info) {
       const value = item.info.price;
       const maxStake = Math.floor(item.info.maxRiskStake);
-      const minStake = Math.ceil(item.info.minRiskStake*10);
-      return { value, maxStake, minStake };
+      const minStake = Math.ceil(PINNACLE_MIN_RISK_STAKE > 0 ? PINNACLE_MIN_RISK_STAKE : item.info.minRiskStake);
+      const rebateType = 1;
+      return { value, maxStake, minStake, rebateType };
     }
   });
 
@@ -363,6 +215,12 @@ export const getSolutionByLatestIors = (iorsInfo, cross_type, retry=false) => {
     odds_side_a: fixFloat(iorsValues[0].value - 1),
     odds_side_b: fixFloat(iorsValues[1].value - 1),
     odds_side_c: fixFloat(iorsValues[2].value - 1),
+    rebate_side_a: fixFloat(((iorsValues[0].rebate ?? 0) / 100), 6),
+    rebate_side_b: fixFloat(((iorsValues[1].rebate ?? 0) / 100), 6),
+    rebate_side_c: fixFloat(((iorsValues[2].rebate ?? 0) / 100), 6),
+    rebate_type_side_a: iorsValues[0].rebateType ?? 0,
+    rebate_type_side_b: iorsValues[1].rebateType ?? 0,
+    rebate_type_side_c: iorsValues[2].rebateType ?? 0,
   };
 
   const sol = eventSolutions(betInfo, true);
@@ -398,61 +256,64 @@ export const getSolutionByLatestIors = (iorsInfo, cross_type, retry=false) => {
     min: fixFloat(win_average * scaleForMin / (gold_side_a + gold_side_b + gold_side_c)),
   }
 
-  return { sol, iors: iorsValues, stakeLimit, winLimit };
+  return { sol, iorsValues, stakeLimit, winLimit };
 }
 
-export const getSoulutionBetResult = async ({ iors, iorsInfo, stakeLimit, stake=0 }) => {
-  const maxStake = stakeLimit.maxGroup.reduce((acc, curr) => acc + curr, 0);
-  const minStake = stakeLimit.minGroup.reduce((acc, curr) => acc + curr, 0);
-  let betStakeGroup = [];
-  if (stake > maxStake || stake < 0) {
-    stake = maxStake;
-    betStakeGroup = stakeLimit.maxGroup;
-  }
-  else if (stake < minStake) {
-    stake = minStake;
-    betStakeGroup = stakeLimit.minGroup;
-  }
-  else {
-    betStakeGroup = stakeLimit.proportions.map(p => p * stake);
-  }
+/**
+ * 创建Polymarket限价挂单 买单
+ */
+export const createPolymarketLimitBuyOrder = async ({ tokenID, price, size, tickSize = "0.01", negRisk = false } = {}) => {
+  return platformPost(`http://127.0.0.1:9021/api/trading/orders/limit`, { tokenID, price, size, tickSize, negRisk })
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('create polymarket limit buy order error', err);
+    return Promise.reject(err);
+  });
+}
 
-  const betInfo = iorsInfo.map((item, index) => {
-    if (item.asks) {
-      const bestPrice = +iors[index].bestPrice;
-      const stakeSize = fixFloat(betStakeGroup[index] / bestPrice, 0); // 必须保证买单金额小数不超过2位
-      return { ...item, stakeSize, bestPrice, betIndex: index, platform: 'polymarket' }
-    }
-    else if (item.info) {
-      const stakeSize = fixFloat(betStakeGroup[index], 0);
-      return { ...item, stakeSize, betIndex: index, platform: 'pinnacle' }
-    }
-  }).sort((a, b) => {
-    if (a.platform === 'polymarket' && b.platform === 'pinnacle') {
-      return -1;
-    }
-    else if (a.platform === 'pinnacle' && b.platform === 'polymarket') {
-      return 1;
-    }
-    else {
-      return 0;
-    }
+/**
+ * 获取Polymarket钱包余额信息
+ * @param {*} param0
+ * @returns
+ */
+export const getPolymarketBalanceAllowance = async ({ wallet = "both" } = {}) => {
+  if (!wallet || !["both", "proxy", "deposit"].includes(wallet)) {
+    throw new Error('invalid wallet', { cause: 400 });
+  }
+  return platformGet(`http://127.0.0.1:9021/api/trading/balance/${wallet}`)
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('get polymarket balance allowance error', err);
+    return Promise.reject(err);
   });
+}
 
-  // return { betInfo };
-  return runSequentially(betInfo.map(item => async() => {
-    if (item.asks) {
-      const result = await polymarketPlaceOrder(item);
-      return [result, item.betIndex]
-    }
-    else if (item.info) {
-      const result = await pinnaclePlaceOrder(item);
-      return [result, item.betIndex]
-    }
-  })).then(results => {
-    return results.sort((a, b) => a[1] - b[1]).map(item => item[0]);
-  }).catch(error => {
-    Logs.errDev(error);
-    return Promise.reject(error);
+/**
+ * Polymarket钱包之间转账
+ * 通过HTTP调用polymarket项目接口,server项目不直接依赖polymarket项目代码
+ * @param {Object} options
+ * @param {string|number} options.amount 转账数量
+ * @param {"proxy"|"deposit"} options.from 来源钱包类型
+ * @param {"proxy"|"deposit"} options.to 目标钱包类型
+ * @returns {Promise<Object>}
+ */
+export const transferPolymarketWallet = async ({ amount, from, to } = {}) => {
+  if (!amount) {
+    throw new Error('amount is required', { cause: 400 });
+  }
+  if (!from || !["proxy", "deposit"].includes(from)) {
+    throw new Error('from is required', { cause: 400 });
+  }
+  if (!to || !["proxy", "deposit"].includes(to)) {
+    throw new Error('to is required', { cause: 400 });
+  }
+  if (from === to) {
+    throw new Error('from and to cannot be the same', { cause: 400 });
+  }
+  return platformPost(`http://127.0.0.1:9021/api/trading/wallet/transfer`, { amount, from, to })
+  .then(res => res.data)
+  .catch(err => {
+    Logs.errDev('transfer polymarket wallet error', err);
+    return Promise.reject(err);
   });
 }

+ 9 - 7
server/models/Partner.js

@@ -1,19 +1,16 @@
 import crypto from "crypto";
 import axios from "axios";
-import dotenv from 'dotenv';
-
-import path from "path";
-import { fileURLToPath } from "url";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
 
 import { getData } from "../libs/cache.js";
 import Logs from "../libs/logs.js";
 
-dotenv.config();
-
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
 
 const IS_DEV = process.env.NODE_ENV == 'development';
+const IS_BID = process.env.PPAI_RUN_MODE == 'BID';
 
 const PARTNER_DATA_FILE = path.join(__dirname, '../data/partner.json');
 
@@ -126,11 +123,16 @@ export const getSoccerGames = async () => {
 
 /**
  * 获取QBoss赔率数据
+ * le listEvents
+ * lo listOdds
+ * lp listPC
+ * hb hideBase
+ * hh hideHalf
  * @returns
  */
 export const getObossOdds = async () => {
   return axiosInstance.get(`${QBOSS_API_BASE}/pstery/get_games_relation`, {
-    params: { lo: true, lp: true, hb: true, hh: true },
+    params: { lo: true, lp: true, hb: true, hh: true, ho: IS_BID },
   }).then(res => res.data);
 }
 

+ 0 - 97
server/models/PartnerGate.js

@@ -1,97 +0,0 @@
-import Logs from "../libs/logs.js";
-import { receivePartnerData } from "./Partner.js";
-import eventSolutions from '../triangle/eventSolutions.js';
-import { placePlatformOrder } from "./Markets.js";
-
-/**
- * 精确浮点数字
- * @param {number} number
- * @param {number} x
- * @returns {number}
- */
-const fixFloat = (number, x=3) => {
-  return parseFloat(number.toFixed(x));
-}
-
-/**
- * 根据赔率获取策略
- * @param {*} params
- * @returns
- */
-const getSolutionWithIors = async(params) => {
-  const { iors, cross_type, base_stake } = params;
-  if (typeof params !== 'object' || params === null || Array.isArray(params)) {
-    return Promise.reject(new Error('params must be an object'));
-  }
-  if (!Array.isArray(iors) || iors.length < 2) {
-    return Promise.reject(new Error('iors must be an array and length must be greater than 2'));
-  }
-  iors.forEach(item => {
-    if (typeof item.v !== 'number' || !Number.isFinite(item.v)) {
-      return Promise.reject(new Error('iors must be an array of numbers'));
-    }
-    if (item.v <= 1) {
-      return Promise.reject(new Error('iors must be an array of numbers greater than 1'));
-    }
-  });
-  if (typeof cross_type !== 'string' || cross_type.length === 0) {
-    return Promise.reject(new Error('cross_type must be a non-empty string'));
-  }
-  if (typeof base_stake !== 'number' || !Number.isFinite(base_stake) || base_stake <= 0) {
-    return Promise.reject(new Error('base_stake must be a positive number'));
-  }
-
-  const base_index = iors.reduce((minIdx, cur, idx) => cur.v < iors[minIdx].v ? idx : minIdx, 0);
-  if (iors.length === 2) {
-    iors.push({ v: 1 });
-  }
-  const betInfo = {
-    cross_type,
-    base_index,
-    base_stake,
-    odds_side_a: fixFloat(iors[0].v - 1),
-    odds_side_b: fixFloat(iors[1].v - 1),
-    odds_side_c: fixFloat(iors[2].v - 1),
-  };
-  const sol = eventSolutions(betInfo, true);
-  return sol;
-}
-
-/**
- * 下注Pinnacle
- * @param {*} data
- * @returns
- */
-const betPinnacle = async (params) => {
-  const { id, ior, stake=0, channel } = params;
-  if (typeof channel !== 'string' || channel.length === 0) {
-    return Promise.reject(new Error('channel is required', { cause: 400 }));
-  }
-  return placePlatformOrder(ior, 'pinnacle', id, stake, channel)
-  .then(ret => {
-    if (ret.errorCode) {
-      const error = new Error('place order error');
-      error.data = ret;
-      error.cause = 400;
-      return Promise.reject(error);
-    }
-    return ret;
-  });
-}
-
-export const gate = async (data) => {
-  return receivePartnerData(data).
-  then(({ action, params }) => {
-    switch (action) {
-      case 'iors.solution':
-        return getSolutionWithIors(params);
-      case 'bet.pinnacle':
-        return betPinnacle(params);
-      default:
-        return Promise.reject(new Error('invalid action'));
-    }
-  });
-}
-
-
-export default { gate };

+ 9 - 18
server/models/Platforms.js

@@ -1,20 +1,19 @@
 import { fork } from "child_process";
-
 import Store from "../state/store.js";
 import ProcessData from "../libs/processData.js";
 import Logs from "../libs/logs.js";
 
-import { getSolutionsWithRelations, getGamesRelationsMap } from "../libs/getGamesRelations.js";
-import { updateSolutions, getSoccerGames, getObossOdds } from "./Partner.js";
-
-// import { getPlatformIorInfo } from "./Markets.js";
+import { getGamesRelationsMap } from "../libs/getGamesRelations.js";
+import { getSoccerGames, getObossOdds } from "./Partner.js";
 
 const getChildOptions = (inspect=9230) => {
   return process.env.NODE_ENV == 'development' ? {
     execArgv: [`--inspect=${inspect}`],
-    stdio: ['pipe', 'pipe', 'pipe', 'ipc']
+    stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
+    env: { ...process.env }
   } : {
-    stdio: ['pipe', 'pipe', 'pipe', 'ipc']
+    stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
+    env: { ...process.env }
   };
 }
 
@@ -49,17 +48,8 @@ triangleData.registerRequest('solutions', solutions => {
   });
   if (changed.update.length || changed.add.length || changed.remove.length) {
     Store.set('solutions', solutions);
-    getSolutionsWithRelations(solutions, 5)
-    .then(solutionsList => {
-      Logs.outDev('get solutions with relations', solutionsList);
-      return updateSolutions(solutionsList)
-    })
-    .then(res => {
-      Logs.outDev('update solutions res', res);
-    })
-    .catch(error => {
-      Logs.err('get and update solutions error', error);
-    });
+    const profitableSolutions = solutions.filter(solution => solution.sol.win_profit_rate > 0);
+    Logs.outDev('profitable solutions', profitableSolutions);
   }
 });
 
@@ -203,6 +193,7 @@ const updateObossOdds = () => {
   getObossOdds()
   .then(res => {
     if (res.statusCode === 200) {
+      // Logs.outDev('update oboss odds', res.data);
       return syncObossOdds(res.data);
     }
     return Promise.reject(new Error(`status code ${res.statusCode}`));

+ 0 - 3
server/models/Translation.js

@@ -1,8 +1,5 @@
-import dotenv from 'dotenv';
 import { TranslationServiceClient } from '@google-cloud/translate';
 
-dotenv.config();
-
 const client = new TranslationServiceClient();
 
 const translateText = async (contents=[], targetLanguageCode='zh-CN') => {

+ 23 - 1
server/routes/games.js

@@ -117,4 +117,26 @@ router.get('/bet_solution', (req, res) => {
   });
 });
 
-export default router;
+router.get('/get_polymarket_balance_allowance/:wallet', (req, res) => {
+  const { wallet } = req.params;
+  Games.getPolymarketBalanceAllowance({ wallet })
+  .then(balanceAllowance => {
+    res.sendSuccess(balanceAllowance);
+  })
+  .catch(err => {
+    res.sendError(err);
+  });
+});
+
+router.post('/transfer_polymarket_wallet', (req, res) => {
+  const { amount, from, to } = req.body;
+  Games.transferPolymarketWallet({ amount, from, to })
+  .then(result => {
+    res.sendSuccess(result);
+  })
+  .catch(err => {
+    res.sendError(err);
+  });
+});
+
+export default router;

+ 0 - 17
server/routes/partnerGate.js

@@ -1,17 +0,0 @@
-import express from 'express';
-const router = express.Router();
-
-import PartnerGate from '../models/PartnerGate.js';
-
-router.post('/gate', (req, res) => {
-  const data = req.body;
-  PartnerGate.gate(data)
-  .then(result => {
-    res.sendSuccess(result);
-  })
-  .catch(err => {
-    res.sendError(err);
-  });
-});
-
-export default router;

+ 2 - 2
server/state/locales.js

@@ -1,5 +1,5 @@
-import path from "path";
-import { fileURLToPath } from "url";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
 
 import Logs from "../libs/logs.js";
 import Cache from "../libs/cache.js";

+ 2 - 2
server/state/store.js

@@ -1,5 +1,5 @@
-import path from "path";
-import { fileURLToPath } from "url";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
 
 import Logs from "../libs/logs.js";
 import Cache from "../libs/cache.js";

+ 8 - 5
server/triangle/trangleCalc.js

@@ -2,6 +2,8 @@ import crypto from 'crypto';
 import iorKeys from './iorKeys.js';
 import eventSolutions from './eventSolutions.js';
 
+const IS_BID = process.env.PPAI_RUN_MODE == 'BID';
+
 /**
  * 精确浮点数字
  * @param {number} number
@@ -70,9 +72,10 @@ const getOptimalSelections = (odds, rules) => {
     if (isValid) {
       const cartesian = cartesianOdds(selection);
       cartesian.forEach(iors => {
-        const hasPm = iors.some(ior => ior.p === 'polymarket');
+        const pmIors = iors.filter(ior => ior.p == 'polymarket');
+        const pmPass = IS_BID ? pmIors.length == 1 : pmIors.length >= 1;
         const iorsCount = new Set(iors.filter(ior => ior.p !== 'no').map(ior => ior.p));
-        if (hasPm && iorsCount.size > 1) {
+        if (pmPass && iorsCount.size > 1) {
           validOptions.push(iors);
         }
       });
@@ -135,9 +138,9 @@ export const eventsCombination = (passableEvents) => {
           odds_side_a: fixFloat(iors[0].v - 1),
           odds_side_b: fixFloat(iors[1].v - 1),
           odds_side_c: fixFloat(iors[2].v - 1),
-          rebate_side_a: fixFloat(((iors[0].b ?? 0) / 100), 4),
-          rebate_side_b: fixFloat(((iors[1].b ?? 0) / 100), 4),
-          rebate_side_c: fixFloat(((iors[2].b ?? 0) / 100), 4),
+          rebate_side_a: fixFloat(((iors[0].b ?? 0) / 100), 6),
+          rebate_side_b: fixFloat(((iors[1].b ?? 0) / 100), 6),
+          rebate_side_c: fixFloat(((iors[2].b ?? 0) / 100), 6),
           rebate_type_side_a: iors[0].t ?? 0,
           rebate_type_side_b: iors[1].t ?? 0,
           rebate_type_side_c: iors[2].t ?? 0,

+ 2 - 8
web/src/main.js

@@ -1,17 +1,11 @@
 import { createApp } from 'vue';
-import { Form, Menu, PageHeader, Button, Input, List, Table } from 'ant-design-vue';
+import Antd from 'ant-design-vue';
 import router from './router';
 import main from '@/main.vue';
 
 import 'ant-design-vue/dist/reset.css';
 
 const app = createApp(main);
-app.use(Menu);
-app.use(Form);
-app.use(PageHeader);
-app.use(Button);
-app.use(Input);
-app.use(List);
-app.use(Table);
+app.use(Antd);
 app.use(router);
 app.mount('#app');

+ 5 - 11
web/src/main.vue

@@ -1,7 +1,7 @@
 <script setup>
 import { ref, watch } from 'vue';
 import { RouterView, useRoute, useRouter } from 'vue-router';
-import { AccountBookOutlined, BookOutlined, LogoutOutlined, TeamOutlined, TrophyOutlined } from '@ant-design/icons-vue';
+import { AccountBookOutlined, LogoutOutlined, TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
 import { message } from 'ant-design-vue';
 import { authState, logout } from '@/stores/auth';
 
@@ -42,23 +42,17 @@ const handleLogout = async () => {
         </template>
         策略
       </a-menu-item>
-      <a-menu-item key="leagues">
-        <template #icon>
-          <team-outlined />
-        </template>
-        联赛
-      </a-menu-item>
       <a-menu-item key="games">
         <template #icon>
           <trophy-outlined />
         </template>
         比赛
       </a-menu-item>
-      <a-menu-item key="locales">
+      <a-menu-item key="wallet">
         <template #icon>
-          <book-outlined />
+          <wallet-outlined />
         </template>
-        翻译
+        钱包
       </a-menu-item>
     </a-menu>
 
@@ -74,7 +68,7 @@ const handleLogout = async () => {
   </header>
 
   <router-view v-slot="{ Component }">
-    <keep-alive :include="['games', 'leagues', 'locales']">
+    <keep-alive :include="['games', 'leagues', 'locales', 'wallet']">
       <component :is="Component" />
     </keep-alive>
   </router-view>

+ 6 - 0
web/src/router/index.js

@@ -3,6 +3,7 @@ import HomeView from '@/views/home.vue';
 import GamesView from '@/views/games.vue';
 import LeaguesView from '@/views/leagues.vue';
 import LocalesView from '@/views/locales.vue';
+import WalletView from '@/views/wallet.vue';
 import LoginView from '@/views/login.vue';
 import { authState, checkAuth } from '@/stores/auth';
 
@@ -39,6 +40,11 @@ const router = createRouter({
       name: 'locales',
       component: LocalesView
     },
+    {
+      path: '/wallet',
+      name: 'wallet',
+      component: WalletView
+    },
   ],
 });
 

+ 333 - 30
web/src/views/home.vue

@@ -1,20 +1,39 @@
 <script setup>
 import dayjs from 'dayjs';
-import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
+import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
 import { message } from 'ant-design-vue';
-import { ReloadOutlined } from '@ant-design/icons-vue';
+import { BarChartOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons-vue';
 import api from '@/libs/api';
 
 const search = ref('');
 const games = ref(null);
 const refreshTimer = ref(null);
+const minProfitRate = ref(0);
+const loading = ref(false);
+const minProfitRateStorageKey = 'home:min_profit_rate';
+
+const platformLabels = {
+  polymarket: 'Polymarket',
+  pinnacle: 'Pinnacle',
+  huangguan: '皇冠',
+  obsports: 'OB',
+};
+
+const sideLabels = ['A', 'B', 'C'];
+
+const getStoredMinProfitRate = () => {
+  const storedValue = localStorage.getItem(minProfitRateStorageKey);
+  const parsedValue = Number(storedValue);
+  return Number.isFinite(parsedValue) ? parsedValue : 0;
+};
 
 const onSearch = (value) => {
   search.value = value;
 };
 
 const updateSolutions = () => {
-  api.get('/api/games/get_solutions', { params: { min_profit_rate: 0 } }).then(res => {
+  loading.value = true;
+  api.get('/api/games/get_solutions', { params: { min_profit_rate: minProfitRate.value ?? 0 } }).then(res => {
     if (res.data.statusCode === 200) {
       games.value = res.data.data;
     }
@@ -25,6 +44,9 @@ const updateSolutions = () => {
   .catch(err => {
     message.error(err.response?.data?.message ?? err.message);
     console.error(err);
+  })
+  .finally(() => {
+    loading.value = false;
   });
 }
 
@@ -62,12 +84,93 @@ const betSolution = (sid, stake=0) => {
   });
 }
 
+const formatNumber = (value, precision = 2) => {
+  const number = Number(value);
+  if (!Number.isFinite(number)) {
+    return '-';
+  }
+  return number.toFixed(precision);
+};
+
+const formatRate = (value) => {
+  return `${formatNumber(value, 3)}%`;
+};
+
+const formatStake = (value) => {
+  return formatNumber(value, 2);
+};
+
+const formatDateTime = (timestamp) => {
+  return timestamp ? dayjs(timestamp).format('MM-DD HH:mm:ss') : '-';
+};
+
+const formatJson = (value) => {
+  return JSON.stringify(value ?? {}, null, 2);
+};
+
+const getProfitColor = (rate) => {
+  const value = Number(rate);
+  if (value > 0) {
+    return 'green';
+  }
+  if (value < 0) {
+    return 'red';
+  }
+  return 'default';
+};
+
+const getPlatformLabel = (platform) => {
+  return platformLabels[platform] ?? platform ?? '-';
+};
+
+const getGameName = (platform = {}) => {
+  const home = platform.localesTeamHomeName || platform.teamHomeName || '-';
+  const away = platform.localesTeamAwayName || platform.teamAwayName || '-';
+  return `${home} vs ${away}`;
+};
+
+const getSolutionStakeRows = (solution) => {
+  const sol = solution.sol ?? {};
+  return sideLabels.map((side, index) => {
+    const key = side.toLowerCase();
+    const cpr = solution.cpr?.[index] ?? {};
+    return {
+      side,
+      platform: getPlatformLabel(cpr.p),
+      market: cpr.k ?? '-',
+      odds: cpr.v ?? '-',
+      rebate: cpr.b ?? 0,
+      stake: sol[`gold_side_${key}`],
+      win: sol[`win_side_${key}`],
+      active: solution.cpr?.[index] || Number(sol[`gold_side_${key}`] ?? 0) !== 0,
+    };
+  }).filter(item => item.active);
+};
+
+const getSolutionMetricRows = (solution) => {
+  const sol = solution.sol ?? {};
+  return [
+    { label: 'SID', value: solution.sid },
+    { label: '平均收益', value: formatStake(sol.win_average) },
+    { label: '收益率', value: formatRate(sol.win_profit_rate) },
+    { label: '交叉类型', value: sol.cross_type ?? '-' },
+    { label: '基准边', value: sideLabels[sol.base_index] ?? sol.base_index ?? '-' },
+    { label: '基准本金', value: formatStake(sol.base_stake) },
+    { label: '规则', value: solution.rule ?? '-' },
+    { label: '组合', value: solution.cross ?? '-' },
+    { label: '更新时间', value: formatDateTime(solution.timestamp) },
+  ];
+};
+
 const solutionsFiltered = computed(() => {
   const searchValue = search.value.trim().toLowerCase();
-  return games.value?.filter(item => {
-    const { platforms: { polymarket, pinnacle }, timestamp } = item;
-    const dateTime = dayjs(timestamp).format('MM-DD HH:mm');
-    item.dateTime = dateTime;
+  return games.value?.map(item => ({
+    ...item,
+    dateTime: formatDateTime(item.timestamp),
+    bestProfitRate: item.solutions?.[0]?.sol?.win_profit_rate,
+    solutionCount: item.solutions?.length ?? 0,
+  })).filter(item => {
+    const { platforms: { polymarket = {}, pinnacle = {} } = {} } = item;
     return !searchValue
     || polymarket.leagueName?.toLowerCase().includes(searchValue)
     || polymarket.teamHomeName?.toLowerCase().includes(searchValue)
@@ -78,12 +181,22 @@ const solutionsFiltered = computed(() => {
   });
 });
 
+const solutionTotal = computed(() => {
+  return solutionsFiltered.value?.reduce((total, item) => total + (item.solutions?.length ?? 0), 0) ?? 0;
+});
+
 const refresh = () => {
   updateSolutions();
 }
 
+watch(minProfitRate, (value) => {
+  localStorage.setItem(minProfitRateStorageKey, String(value ?? 0));
+});
+
 onMounted(() => {
   // console.log('home mounted');
+  minProfitRate.value = getStoredMinProfitRate();
+  console.log('minProfitRate', minProfitRate.value);
   refresh();
   refreshTimer.value = setInterval(refresh, 5_000);
 });
@@ -98,6 +211,17 @@ onUnmounted(() => {
 <template>
   <a-page-header title="策略">
     <template #extra>
+      <a-tag color="blue">赛事 {{ solutionsFiltered?.length ?? 0 }}</a-tag>
+      <a-tag color="green">方案 {{ solutionTotal }}</a-tag>
+      <a-input-number
+        v-model:value="minProfitRate"
+        :min="-10"
+        :step="0.1"
+        :precision="2"
+        addon-after="%"
+        @change="refresh"
+        style="width: 110px;"
+      />
       <a-input-search
         v-model:value="search"
         placeholder="搜索"
@@ -114,17 +238,105 @@ onUnmounted(() => {
   </a-page-header>
 
   <div class="solutions-container">
-    <a-list :data-source="solutionsFiltered" size="small">
+    <a-empty
+      v-if="!loading && !solutionsFiltered?.length"
+      description="暂无策略"
+    />
+    <a-list
+      v-else
+      :data-source="solutionsFiltered"
+      :loading="loading"
+      size="small"
+      class="solutions-list"
+    >
       <template #renderItem="{ item }">
-        <a-list-item>
+        <a-list-item class="game-item">
           <div class="game-info">
-            <div class="game-league-name">{{ item.platforms.pinnacle.leagueName }} [{{ item.platforms.polymarket.leagueName }}]</div>
-            <div class="game-team-name home-team-name">{{ item.platforms.pinnacle.teamHomeName }} [{{ item.platforms.polymarket.teamHomeName }}]</div>
-            <div class="game-team-name away-team-name">{{ item.platforms.pinnacle.teamAwayName }} [{{ item.platforms.polymarket.teamAwayName }}]</div>
-            <div class="game-date-time">{{ item.dateTime }}</div>
+            <div class="game-title">
+              <span>{{ getGameName(item.platforms?.pinnacle) }}</span>
+              <a-tag :color="getProfitColor(item.bestProfitRate)">
+                最优 {{ formatRate(item.bestProfitRate) }}
+              </a-tag>
+            </div>
+            <div class="game-meta">
+              <span>{{ item.platforms?.pinnacle?.leagueName || '-' }}</span>
+              <span>Polymarket: {{ getGameName(item.platforms?.polymarket) }}</span>
+              <span>{{ item.dateTime }}</span>
+              <span>RID: {{ item.id }}</span>
+            </div>
           </div>
-          <div class="solutions-list">
-            <a-button v-for="solution in item.solutions" @click="betSolution(solution.sid)">{{ solution.sol.win_profit_rate }}</a-button>
+          <div class="solution-stack">
+            <div
+              v-for="(solution, index) in item.solutions"
+              :key="solution.sid"
+              class="solution-panel"
+            >
+              <div class="solution-header">
+                <div class="solution-title">
+                  <a-tag :color="getProfitColor(solution.sol?.win_profit_rate)">
+                    #{{ index + 1 }} {{ formatRate(solution.sol?.win_profit_rate) }}
+                  </a-tag>
+                  <span>{{ solution.cross }}</span>
+                  <span>{{ solution.rule }}</span>
+                </div>
+                <div class="solution-actions">
+                  <a-button size="small" @click="getSolutionIorsInfo(solution.sid)">
+                    <template #icon>
+                      <bar-chart-outlined />
+                    </template>
+                    盘口
+                  </a-button>
+                  <a-button size="small" type="primary" @click="betSolution(solution.sid)">
+                    <template #icon>
+                      <thunderbolt-outlined />
+                    </template>
+                    执行
+                  </a-button>
+                </div>
+              </div>
+
+              <div class="metric-grid">
+                <div
+                  v-for="metric in getSolutionMetricRows(solution)"
+                  :key="metric.label"
+                  class="metric-item"
+                >
+                  <span>{{ metric.label }}</span>
+                  <strong>{{ metric.value }}</strong>
+                </div>
+              </div>
+
+              <div class="stake-table">
+                <div class="stake-row stake-head">
+                  <span>边</span>
+                  <span>平台</span>
+                  <span>盘口</span>
+                  <span>赔率</span>
+                  <span>返佣</span>
+                  <span>投注额</span>
+                  <span>命中收益</span>
+                </div>
+                <div
+                  v-for="row in getSolutionStakeRows(solution)"
+                  :key="row.side"
+                  class="stake-row"
+                >
+                  <span>{{ row.side }}</span>
+                  <span>{{ row.platform }}</span>
+                  <span class="market-key">{{ row.market }}</span>
+                  <span>{{ row.odds }}</span>
+                  <span>{{ row.rebate }}%</span>
+                  <span>{{ formatStake(row.stake) }}</span>
+                  <span>{{ formatStake(row.win) }}</span>
+                </div>
+              </div>
+
+              <a-collapse ghost size="small">
+                <a-collapse-panel key="json" header="原始数据">
+                  <pre>{{ formatJson(solution) }}</pre>
+                </a-collapse-panel>
+              </a-collapse>
+            </div>
           </div>
         </a-list-item>
       </template>
@@ -143,31 +355,122 @@ onUnmounted(() => {
   }
 }
 .solutions-container {
-  // display: flex;
-  // flex-direction: row;
-  // justify-content: space-between;
-  // align-items: stretch;
   height: calc(100vh - 126px);
   padding: 15px;
-  // gap: 15px;
+  overflow: auto;
   border-top: 1px solid rgba(5, 5, 5, 0.06);
 }
 .solutions-list {
-  button {
-    &:not(:first-child) {
-      margin-left: 10px;
-    }
+  display: grid;
+  gap: 14px;
+}
+.game-item {
+  display: block;
+  padding: 14px 0;
+}
+.game-info {
+  display: grid;
+  gap: 6px;
+  margin-bottom: 12px;
+}
+.game-title {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  font-size: 16px;
+  font-weight: 600;
+}
+.game-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  color: #666;
+  font-size: 12px;
+}
+.solution-stack {
+  display: grid;
+  gap: 12px;
+}
+.solution-panel {
+  display: grid;
+  gap: 10px;
+  padding: 12px;
+  border: 1px solid rgba(5, 5, 5, 0.08);
+  border-radius: 6px;
+  background: #fff;
+}
+.solution-header {
+  display: flex;
+  gap: 10px;
+  align-items: center;
+  justify-content: space-between;
+}
+.solution-title,
+.solution-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+}
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
+  gap: 8px;
+}
+.metric-item {
+  display: grid;
+  gap: 3px;
+  padding: 8px;
+  background: #f7f8fa;
+  border: 1px solid rgba(5, 5, 5, 0.06);
+  border-radius: 4px;
+  span {
+    color: #777;
+    font-size: 12px;
+  }
+  strong {
+    overflow: hidden;
+    font-size: 13px;
+    font-weight: 600;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 }
-.game-team-name {
-  &.home-team-name {
-    color: rgb(0, 107, 230);
+.stake-table {
+  overflow: auto;
+  border: 1px solid rgba(5, 5, 5, 0.06);
+  border-radius: 4px;
+}
+.stake-row {
+  display: grid;
+  grid-template-columns: 42px 96px minmax(180px, 1fr) 80px 80px 90px 90px;
+  min-width: 760px;
+  span {
+    padding: 7px 8px;
+    border-top: 1px solid rgba(5, 5, 5, 0.06);
+    font-size: 12px;
   }
-  &.away-team-name {
-    color: rgb(255, 56, 96);
+  &:first-child span {
+    border-top: 0;
   }
 }
-.game-date-time {
+.stake-head {
+  background: #f7f8fa;
   color: #666;
+  font-weight: 600;
+}
+.market-key {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  word-break: break-all;
+}
+pre {
+  max-height: 360px;
+  margin: 0;
+  padding: 10px;
+  overflow: auto;
+  background: #f7f8fa;
+  border: 1px solid rgba(5, 5, 5, 0.06);
+  border-radius: 4px;
 }
 </style>

+ 390 - 0
web/src/views/wallet.vue

@@ -0,0 +1,390 @@
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import { message } from 'ant-design-vue';
+import { ReloadOutlined, SwapOutlined } from '@ant-design/icons-vue';
+import api from '@/libs/api';
+
+const walletType = ref('both');
+const loading = ref(false);
+const transferLoading = ref(false);
+const transferModalOpen = ref(false);
+const wallets = ref([]);
+const transferForm = ref({
+  amount: '',
+  from: 'proxy',
+  to: 'deposit',
+});
+
+const MAX_UINT256 = '115792089237316195423570985008687907853269984665640564039457584007913129639935';
+const TOKEN_DECIMALS = 6;
+
+const columns = [
+  { title: '类型', dataIndex: 'type', key: 'type', width: 100 },
+  { title: '地址', dataIndex: 'address', key: 'address' },
+  { title: '签名类型', dataIndex: 'signatureType', key: 'signatureType', width: 140 },
+  { title: '链上 pUSD', dataIndex: 'chainPusdBalance', key: 'chainPusdBalance', width: 140 },
+  { title: 'CLOB 余额', dataIndex: 'clobBalance', key: 'clobBalance', width: 140 },
+  { title: 'CLOB 授权', dataIndex: 'clobAllowanceSummary', key: 'clobAllowanceSummary', width: 180 },
+];
+
+const walletOptions = [
+  { label: '全部', value: 'both' },
+  { label: 'Deposit', value: 'deposit' },
+  { label: 'Proxy', value: 'proxy' },
+];
+
+const transferWalletOptions = [
+  { label: 'Proxy', value: 'proxy' },
+  { label: 'Deposit', value: 'deposit' },
+];
+
+const toBigInt = (value) => {
+  try {
+    return BigInt(value ?? 0);
+  }
+  catch {
+    return 0n;
+  }
+};
+
+const formatTokenAmount = (value, decimals = TOKEN_DECIMALS) => {
+  if (value === undefined || value === null || value === '') {
+    return '-';
+  }
+
+  const raw = toBigInt(value);
+  const divisor = 10n ** BigInt(decimals);
+  const whole = raw / divisor;
+  const fraction = (raw % divisor).toString().padStart(decimals, '0').replace(/0+$/, '');
+  return fraction ? `${whole}.${fraction}` : whole.toString();
+};
+
+const getAllowanceRows = (allowances = {}) => {
+  return Object.entries(allowances).map(([spender, value]) => {
+    const raw = String(value ?? '0');
+    const amount = toBigInt(raw);
+    return {
+      spender,
+      raw,
+      amount,
+      label: raw === MAX_UINT256 ? '无限授权' : formatTokenAmount(raw),
+      authorized: amount > 0n,
+      unlimited: raw === MAX_UINT256,
+    };
+  });
+};
+
+const getAllowanceSummary = (allowanceRows) => {
+  const total = allowanceRows.length;
+  const authorized = allowanceRows.filter(item => item.authorized).length;
+
+  if (!total) {
+    return {
+      text: '无授权数据',
+      color: 'default',
+      description: 'CLOB 未返回 spender 授权列表',
+    };
+  }
+
+  if (authorized === 0) {
+    return {
+      text: '未授权',
+      color: 'red',
+      description: `${total} 个 CLOB 合约均无可用额度`,
+    };
+  }
+
+  if (authorized === total) {
+    const unlimited = allowanceRows.every(item => item.unlimited);
+    return {
+      text: unlimited ? '已无限授权' : '已授权',
+      color: unlimited ? 'green' : 'blue',
+      description: `${authorized}/${total} 个 CLOB 合约可用`,
+    };
+  }
+
+  return {
+    text: '部分授权',
+    color: 'orange',
+    description: `${authorized}/${total} 个 CLOB 合约可用`,
+  };
+};
+
+const displayWallets = computed(() => {
+  return wallets.value.map(item => {
+    const allowance = item.clobBalanceAllowance ?? {};
+    const allowanceRows = getAllowanceRows(allowance.allowances);
+    const clobAllowanceSummary = getAllowanceSummary(allowanceRows);
+
+    return {
+      ...item,
+      key: item.type,
+      clobBalance: formatTokenAmount(allowance.balance),
+      allowanceRows,
+      clobAllowanceSummary,
+    };
+  });
+});
+
+const formatJson = (value) => {
+  return JSON.stringify(value ?? {}, null, 2);
+};
+
+const refresh = () => {
+  loading.value = true;
+  api.get(`/api/games/get_polymarket_balance_allowance/${walletType.value}`)
+  .then(res => {
+    if (res.data.statusCode === 200) {
+      wallets.value = Array.isArray(res.data.data) ? res.data.data : [res.data.data].filter(Boolean);
+    }
+    else {
+      throw new Error(res.data.message);
+    }
+  })
+  .catch(err => {
+    message.error(err.response?.data?.message ?? err.message);
+    console.error(err);
+  })
+  .finally(() => {
+    loading.value = false;
+  });
+};
+
+const onWalletTypeChange = () => {
+  refresh();
+};
+
+const openTransferModal = () => {
+  transferModalOpen.value = true;
+};
+
+const closeTransferModal = () => {
+  if (transferLoading.value) {
+    return;
+  }
+  transferModalOpen.value = false;
+};
+
+const getOppositeWallet = (wallet) => {
+  return wallet === 'proxy' ? 'deposit' : 'proxy';
+};
+
+const onTransferFromChange = (value) => {
+  transferForm.value.to = getOppositeWallet(value);
+};
+
+const onTransferToChange = (value) => {
+  transferForm.value.from = getOppositeWallet(value);
+};
+
+const submitTransfer = () => {
+  const { amount, from, to } = transferForm.value;
+  if (!amount || Number(amount) <= 0) {
+    message.warning('请输入有效转账数量');
+    return;
+  }
+  if (!from || !to || from === to) {
+    message.warning('请选择有效转账方向');
+    return;
+  }
+
+  transferLoading.value = true;
+  api.post('/api/games/transfer_polymarket_wallet', { amount, from, to })
+  .then(res => {
+    if (res.data.statusCode === 200) {
+      message.success('转账已提交');
+      transferModalOpen.value = false;
+      transferForm.value.amount = '';
+      refresh();
+    }
+    else {
+      throw new Error(res.data.message);
+    }
+  })
+  .catch(err => {
+    message.error(err.response?.data?.message ?? err.message);
+    console.error(err);
+  })
+  .finally(() => {
+    transferLoading.value = false;
+  });
+};
+
+onMounted(() => {
+  refresh();
+});
+</script>
+
+<template>
+  <a-page-header title="钱包">
+    <template #extra>
+      <a-segmented
+        v-model:value="walletType"
+        :options="walletOptions"
+        @change="onWalletTypeChange"
+      />
+      <a-button type="primary" @click="openTransferModal">
+        <template #icon>
+          <swap-outlined />
+        </template>
+        转账
+      </a-button>
+      <a-button :loading="loading" @click="refresh">
+        <template #icon>
+          <reload-outlined />
+        </template>
+        刷新
+      </a-button>
+    </template>
+  </a-page-header>
+
+  <div class="wallet-container">
+    <a-table
+      :columns="columns"
+      :data-source="displayWallets"
+      :loading="loading"
+      :pagination="false"
+      size="small"
+      bordered
+    >
+      <template #bodyCell="{ column, record, text }">
+        <template v-if="column.key === 'type'">
+          <a-tag :color="record.type === 'deposit' ? 'blue' : 'green'">
+            {{ record.type }}
+          </a-tag>
+        </template>
+        <template v-else-if="column.key === 'address'">
+          <span class="address">{{ text }}</span>
+        </template>
+        <template v-else-if="column.key === 'clobAllowanceSummary'">
+          <a-tooltip :title="record.clobAllowanceSummary.description">
+            <a-tag :color="record.clobAllowanceSummary.color">
+              {{ record.clobAllowanceSummary.text }}
+            </a-tag>
+          </a-tooltip>
+        </template>
+      </template>
+      <template #expandedRowRender="{ record }">
+        <div class="wallet-detail">
+          <div>
+            <strong>Owner</strong>
+            <span class="address">{{ record.owner }}</span>
+          </div>
+          <div>
+            <strong>CLOB 授权明细</strong>
+            <div class="allowance-list">
+              <div
+                v-for="item in record.allowanceRows"
+                :key="item.spender"
+                class="allowance-item"
+              >
+                <span class="address">{{ item.spender }}</span>
+                <a-tag :color="item.authorized ? 'green' : 'red'">
+                  {{ item.authorized ? item.label : '未授权' }}
+                </a-tag>
+              </div>
+              <a-empty
+                v-if="!record.allowanceRows.length"
+                description="无授权数据"
+              />
+            </div>
+          </div>
+          <div>
+            <strong>CLOB 详情</strong>
+            <pre>{{ formatJson(record.clobBalanceAllowance) }}</pre>
+          </div>
+        </div>
+      </template>
+    </a-table>
+  </div>
+
+  <a-modal
+    v-model:open="transferModalOpen"
+    title="钱包转账"
+    :confirm-loading="transferLoading"
+    ok-text="提交"
+    cancel-text="取消"
+    @ok="submitTransfer"
+    @cancel="closeTransferModal"
+  >
+    <a-form layout="vertical" class="transfer-form">
+      <div class="transfer-wallets">
+        <a-form-item label="转出" required>
+          <a-select
+            v-model:value="transferForm.from"
+            :options="transferWalletOptions"
+            @change="onTransferFromChange"
+          />
+        </a-form-item>
+        <a-form-item label="转入" required>
+          <a-select
+            v-model:value="transferForm.to"
+            :options="transferWalletOptions"
+            @change="onTransferToChange"
+          />
+        </a-form-item>
+      </div>
+      <a-form-item label="数量" required>
+        <a-input-number
+          v-model:value="transferForm.amount"
+          class="amount-input"
+          string-mode
+          :min="0"
+          :precision="6"
+          placeholder="输入 pUSD 数量"
+        />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<style lang="scss" scoped>
+.wallet-container {
+  height: calc(100vh - 126px);
+  padding: 15px;
+  border-top: 1px solid rgba(5, 5, 5, 0.06);
+}
+.address {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  font-size: 12px;
+  word-break: break-all;
+}
+.wallet-detail {
+  display: grid;
+  gap: 12px;
+  pre {
+    margin: 8px 0 0;
+    padding: 12px;
+    overflow: auto;
+    background: #f7f8fa;
+    border: 1px solid rgba(5, 5, 5, 0.06);
+    border-radius: 6px;
+  }
+}
+.allowance-list {
+  display: grid;
+  gap: 8px;
+  margin-top: 8px;
+}
+.allowance-item {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 12px;
+  align-items: center;
+  padding: 8px 10px;
+  background: #f7f8fa;
+  border: 1px solid rgba(5, 5, 5, 0.06);
+  border-radius: 6px;
+}
+.transfer-form {
+  padding-top: 8px;
+}
+.transfer-wallets {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+.amount-input {
+  width: 100%;
+}
+</style>

+ 1 - 1
web/vite.config.js

@@ -16,7 +16,7 @@ export default defineConfig({
     },
   },
   server: {
-    port: 9021,
+    port: 9023,
     open: true,
     host: '0.0.0.0',
     proxy: {

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است