Kaynağa Gözat

feat: 从 Byreal API 同步仓位数据(PNL/APR/Fee/Bonus)

- 新增 Byreal API 同步模块,拉取 status=0 的仓位列表
- DB 新增 pnl_usd/pnl_percent/apr/bonus_usd/earned_usd/liquidity_usd 字段
- 不在 API 返回中的 active 仓位自动标记为 closed
- Positions 页面新增 Sync from Byreal 按钮和 PNL/Fee/Bonus/APR 列

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zhangchunrui 1 hafta önce
ebeveyn
işleme
29ae03bf4a

+ 13 - 0
src/app/api/sync/route.ts

@@ -0,0 +1,13 @@
+import { NextResponse } from 'next/server'
+import { syncFromByrealApi } from '@/lib/byreal-sync'
+
+export async function POST() {
+  try {
+    const result = await syncFromByrealApi()
+    return NextResponse.json(result)
+  } catch (e) {
+    const msg = e instanceof Error ? e.message : String(e)
+    console.error('[API] Byreal sync failed:', msg)
+    return NextResponse.json({ error: msg }, { status: 500 })
+  }
+}

+ 185 - 97
src/app/positions/page.tsx

@@ -37,6 +37,12 @@ interface PositionRow {
   amount_b: string
   symbol_a: string
   symbol_b: string
+  pnl_usd: string | null
+  pnl_percent: string | null
+  apr: string | null
+  bonus_usd: string | null
+  earned_usd: string | null
+  liquidity_usd: string | null
   status: string
   created_at: string
 }
@@ -51,6 +57,13 @@ function formatPrice(price: string): string {
   return n.toExponential(3)
 }
 
+function formatUsd(value: string | null): string {
+  if (!value) return '-'
+  const n = parseFloat(value)
+  if (isNaN(n)) return value
+  return n >= 0 ? `$${n.toFixed(2)}` : `-$${Math.abs(n).toFixed(2)}`
+}
+
 export default function PositionsPage() {
   const { data: positions, mutate } = useSWR('/api/positions', fetcher, { refreshInterval: 5000 })
   const [closingId, setClosingId] = useState<number | null>(null)
@@ -58,10 +71,32 @@ export default function PositionsPage() {
   const [error, setError] = useState<string | null>(null)
   const [confirmAction, setConfirmAction] = useState<{ type: 'close' | 'delete'; row: PositionRow } | null>(null)
   const [showClosed, setShowClosed] = useState(false)
+  const [syncing, setSyncing] = useState(false)
+  const [syncResult, setSyncResult] = useState<string | null>(null)
 
   const allRows: PositionRow[] = positions || []
   const rows = showClosed ? allRows : allRows.filter((r) => r.status !== 'closed')
 
+  const handleSync = async () => {
+    setSyncing(true)
+    setSyncResult(null)
+    setError(null)
+    try {
+      const res = await fetch('/api/sync', { method: 'POST' })
+      const data = await res.json()
+      if (!res.ok) {
+        setError(data.error || 'Sync failed')
+      } else {
+        setSyncResult(`Synced ${data.synced} positions, closed ${data.closed}, API total ${data.total}`)
+        mutate()
+      }
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Sync failed')
+    } finally {
+      setSyncing(false)
+    }
+  }
+
   const handleClose = async (row: PositionRow) => {
     setClosingId(row.id)
     setError(null)
@@ -116,11 +151,16 @@ export default function PositionsPage() {
     <div className="space-y-6">
       <div className="flex items-center justify-between">
         <h2 className="text-lg font-semibold">Position Mappings</h2>
-        <div className="flex items-center gap-2">
-          <Switch checked={showClosed} onCheckedChange={setShowClosed} id="show-closed" />
-          <Label htmlFor="show-closed" className="text-xs text-muted-foreground cursor-pointer">
-            Show closed
-          </Label>
+        <div className="flex items-center gap-3">
+          <Button onClick={handleSync} disabled={syncing} variant="outline" size="sm" className="text-xs">
+            {syncing ? 'Syncing...' : 'Sync from Byreal'}
+          </Button>
+          <div className="flex items-center gap-2">
+            <Switch checked={showClosed} onCheckedChange={setShowClosed} id="show-closed" />
+            <Label htmlFor="show-closed" className="text-xs text-muted-foreground cursor-pointer">
+              Show closed
+            </Label>
+          </div>
         </div>
       </div>
 
@@ -133,6 +173,15 @@ export default function PositionsPage() {
         </div>
       )}
 
+      {syncResult && (
+        <div className="rounded-lg border border-green-500/30 bg-green-500/10 p-3 text-xs text-green-400">
+          {syncResult}
+          <button onClick={() => setSyncResult(null)} className="ml-2 underline">
+            dismiss
+          </button>
+        </div>
+      )}
+
       <Card>
         <CardContent className="p-0">
           {rows.length === 0 ? (
@@ -142,116 +191,155 @@ export default function PositionsPage() {
               <Table>
                 <TableHeader>
                   <TableRow>
-                    <TableHead>Target</TableHead>
-                    <TableHead>Target NFT</TableHead>
-                    <TableHead>Our NFT</TableHead>
                     <TableHead>Pool</TableHead>
                     <TableHead>Size</TableHead>
+                    <TableHead>PNL</TableHead>
+                    <TableHead>Fee</TableHead>
+                    <TableHead>Bonus</TableHead>
+                    <TableHead>APR</TableHead>
                     <TableHead>Price Range</TableHead>
+                    <TableHead>Target</TableHead>
+                    <TableHead>Our NFT</TableHead>
                     <TableHead>Status</TableHead>
                     <TableHead>Created</TableHead>
                     <TableHead>Actions</TableHead>
                   </TableRow>
                 </TableHeader>
                 <TableBody>
-                  {rows.map((row) => (
-                    <TableRow key={row.id}>
-                      <TableCell className="text-muted-foreground">
-                        {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
-                      </TableCell>
-                      <TableCell>
-                        <a
-                          href={`https://solscan.io/token/${row.target_nft_mint}`}
-                          target="_blank"
-                          rel="noopener noreferrer"
-                          className="text-primary hover:underline"
-                        >
-                          {row.target_nft_mint.slice(0, 6)}...
-                        </a>
-                      </TableCell>
-                      <TableCell>
-                        {row.our_nft_mint ? (
+                  {rows.map((row) => {
+                    const pnlNum = parseFloat(row.pnl_usd || '0')
+                    const pnlColor = pnlNum > 0 ? 'text-green-400' : pnlNum < 0 ? 'text-red-400' : 'text-muted-foreground'
+
+                    return (
+                      <TableRow key={row.id}>
+                        <TableCell className="text-muted-foreground">
+                          {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
+                        </TableCell>
+                        <TableCell>
+                          {(row.liquidity_usd || row.size_usd) ? (
+                            <div>
+                              <span className="text-foreground font-medium">
+                                {formatUsd(row.liquidity_usd || row.size_usd)}
+                              </span>
+                              {row.amount_a && row.symbol_a && (
+                                <div className="text-muted-foreground text-[10px] leading-tight mt-0.5">
+                                  {row.amount_a} {row.symbol_a}
+                                  {row.amount_b && ` + ${row.amount_b} ${row.symbol_b}`}
+                                </div>
+                              )}
+                            </div>
+                          ) : (
+                            <span className="text-muted-foreground">-</span>
+                          )}
+                        </TableCell>
+                        <TableCell>
+                          {row.pnl_usd ? (
+                            <div>
+                              <span className={`font-medium ${pnlColor}`}>{formatUsd(row.pnl_usd)}</span>
+                              {row.pnl_percent && (
+                                <div className={`text-[10px] ${pnlColor}`}>
+                                  {parseFloat(row.pnl_percent) >= 0 ? '+' : ''}{parseFloat(row.pnl_percent).toFixed(2)}%
+                                </div>
+                              )}
+                            </div>
+                          ) : (
+                            <span className="text-muted-foreground">-</span>
+                          )}
+                        </TableCell>
+                        <TableCell>
+                          <span className={row.earned_usd && parseFloat(row.earned_usd) > 0 ? 'text-green-400' : 'text-muted-foreground'}>
+                            {formatUsd(row.earned_usd)}
+                          </span>
+                        </TableCell>
+                        <TableCell>
+                          <span className={row.bonus_usd && parseFloat(row.bonus_usd) > 0 ? 'text-green-400' : 'text-muted-foreground'}>
+                            {formatUsd(row.bonus_usd)}
+                          </span>
+                        </TableCell>
+                        <TableCell>
+                          {row.apr ? (
+                            <span className="text-foreground">{row.apr}%</span>
+                          ) : (
+                            <span className="text-muted-foreground">-</span>
+                          )}
+                        </TableCell>
+                        <TableCell className="text-muted-foreground text-[11px]">
+                          {row.price_lower && row.price_upper
+                            ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
+                            : `${row.tick_lower} ~ ${row.tick_upper}`}
+                        </TableCell>
+                        <TableCell>
                           <a
-                            href={`https://solscan.io/token/${row.our_nft_mint}`}
+                            href={`https://solscan.io/account/${row.target_address}`}
                             target="_blank"
                             rel="noopener noreferrer"
-                            className="text-primary hover:underline"
+                            className="text-primary hover:underline text-[11px]"
                           >
-                            {row.our_nft_mint.slice(0, 6)}...
+                            {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
                           </a>
-                        ) : (
-                          <span className="text-muted-foreground">pending</span>
-                        )}
-                      </TableCell>
-                      <TableCell className="text-muted-foreground">
-                        {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
-                      </TableCell>
-                      <TableCell>
-                        {row.size_usd ? (
-                          <div>
-                            <span className="text-green-400 font-medium">${row.size_usd}</span>
-                            <div className="text-muted-foreground text-[10px] leading-tight mt-0.5">
-                              {row.amount_a && row.symbol_a && `${row.amount_a} ${row.symbol_a}`}
-                              {row.amount_a && row.amount_b && ' + '}
-                              {row.amount_b && row.symbol_b && `${row.amount_b} ${row.symbol_b}`}
-                            </div>
-                          </div>
-                        ) : (
-                          <span className="text-muted-foreground">-</span>
-                        )}
-                      </TableCell>
-                      <TableCell className="text-muted-foreground">
-                        {row.price_lower && row.price_upper
-                          ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
-                          : `${row.tick_lower} ~ ${row.tick_upper}`}
-                      </TableCell>
-                      <TableCell>
-                        <Badge
-                          variant={
-                            row.status === 'active'
-                              ? 'success'
-                              : row.status === 'closed'
-                                ? 'secondary'
-                                : 'destructive'
-                          }
-                        >
-                          {row.status}
-                        </Badge>
-                      </TableCell>
-                      <TableCell className="text-muted-foreground">
-                        {new Date(row.created_at + 'Z').toLocaleString(undefined, {
-                          month: '2-digit',
-                          day: '2-digit',
-                          hour: '2-digit',
-                          minute: '2-digit',
-                        })}
-                      </TableCell>
-                      <TableCell>
-                        <div className="flex gap-1.5">
-                          {row.status === 'active' && row.our_nft_mint && (
+                        </TableCell>
+                        <TableCell>
+                          {row.our_nft_mint ? (
+                            <a
+                              href={`https://solscan.io/token/${row.our_nft_mint}`}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              className="text-primary hover:underline text-[11px]"
+                            >
+                              {row.our_nft_mint.slice(0, 6)}...
+                            </a>
+                          ) : (
+                            <span className="text-muted-foreground">pending</span>
+                          )}
+                        </TableCell>
+                        <TableCell>
+                          <Badge
+                            variant={
+                              row.status === 'active'
+                                ? 'success'
+                                : row.status === 'closed'
+                                  ? 'secondary'
+                                  : 'destructive'
+                            }
+                          >
+                            {row.status}
+                          </Badge>
+                        </TableCell>
+                        <TableCell className="text-muted-foreground">
+                          {new Date(row.created_at + 'Z').toLocaleString(undefined, {
+                            month: '2-digit',
+                            day: '2-digit',
+                            hour: '2-digit',
+                            minute: '2-digit',
+                          })}
+                        </TableCell>
+                        <TableCell>
+                          <div className="flex gap-1.5">
+                            {row.status === 'active' && row.our_nft_mint && (
+                              <Button
+                                variant="outline"
+                                size="sm"
+                                onClick={() => setConfirmAction({ type: 'close', row })}
+                                disabled={closingId === row.id}
+                                className="text-[10px] h-6 px-2 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
+                              >
+                                {closingId === row.id ? 'Closing...' : 'Close'}
+                              </Button>
+                            )}
                             <Button
-                              variant="outline"
+                              variant="destructive"
                               size="sm"
-                              onClick={() => setConfirmAction({ type: 'close', row })}
-                              disabled={closingId === row.id}
-                              className="text-[10px] h-6 px-2 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
+                              onClick={() => setConfirmAction({ type: 'delete', row })}
+                              disabled={deletingId === row.id}
+                              className="text-[10px] h-6 px-2"
                             >
-                              {closingId === row.id ? 'Closing...' : 'Close'}
+                              {deletingId === row.id ? 'Deleting...' : 'Delete'}
                             </Button>
-                          )}
-                          <Button
-                            variant="destructive"
-                            size="sm"
-                            onClick={() => setConfirmAction({ type: 'delete', row })}
-                            disabled={deletingId === row.id}
-                            className="text-[10px] h-6 px-2"
-                          >
-                            {deletingId === row.id ? 'Deleting...' : 'Delete'}
-                          </Button>
-                        </div>
-                      </TableCell>
-                    </TableRow>
-                  ))}
+                          </div>
+                        </TableCell>
+                      </TableRow>
+                    )
+                  })}
                 </TableBody>
               </Table>
             </div>

+ 111 - 0
src/lib/byreal-sync.ts

@@ -0,0 +1,111 @@
+import { getUserAddress } from './solana/wallet'
+import {
+  getPositionMappings,
+  updatePositionMappingSyncData,
+  updatePositionMappingStatus,
+} from './db/queries'
+
+interface ByrealPosition {
+  poolAddress: string
+  positionAddress: string
+  nftMintAddress: string
+  walletAddress: string
+  upperTick: number
+  lowerTick: number
+  liquidityUsd: number
+  earnedUsd: number
+  earnedUsdPercent: number
+  pnlUsd: number
+  pnlUsdPercent: number
+  bonusUsd: number
+  totalDeposit: number
+  status: number
+  positionAgeMs: number
+  openTime: number
+  copies: number
+}
+
+interface ByrealApiResponse {
+  retCode: number
+  result: {
+    success: boolean
+    data: {
+      positions: ByrealPosition[]
+    }
+  }
+}
+
+/**
+ * Sync positions from Byreal API:
+ * 1. Fetch all open positions (status=0) for our wallet
+ * 2. Update PNL/APR/bonus/fee for matching positions
+ * 3. Mark positions as closed if not found in API response
+ */
+export async function syncFromByrealApi(): Promise<{
+  synced: number
+  closed: number
+  total: number
+}> {
+  const userAddress = getUserAddress().toBase58()
+
+  // Fetch all open positions from Byreal API
+  const url = `https://api2.byreal.io/byreal/api/dex/v2/position/list?userAddress=${userAddress}&page=1&pageSize=200&status=0`
+  const res = await fetch(url)
+  if (!res.ok) {
+    throw new Error(`Byreal API error: ${res.status} ${res.statusText}`)
+  }
+
+  const json: ByrealApiResponse = await res.json()
+  if (!json.result?.success || !json.result?.data?.positions) {
+    throw new Error(`Byreal API returned unexpected response: ${JSON.stringify(json).slice(0, 200)}`)
+  }
+
+  const apiPositions = json.result.data.positions
+  const apiNftMints = new Set(apiPositions.map((p) => p.nftMintAddress))
+
+  // Build lookup by nftMintAddress
+  const apiMap = new Map<string, ByrealPosition>()
+  for (const p of apiPositions) {
+    apiMap.set(p.nftMintAddress, p)
+  }
+
+  // Get all active positions from our DB
+  const dbPositions = getPositionMappings('active')
+
+  let synced = 0
+  let closed = 0
+
+  for (const dbPos of dbPositions) {
+    if (!dbPos.our_nft_mint) continue
+
+    const apiPos = apiMap.get(dbPos.our_nft_mint)
+
+    if (apiPos) {
+      // Position exists in API — sync PNL/APR/bonus/fee data
+      const ageMs = apiPos.positionAgeMs || 0
+      const ageDays = ageMs / (1000 * 60 * 60 * 24)
+      // APR = (earnedUsd / totalDeposit) / ageDays * 365 * 100
+      let apr = ''
+      if (apiPos.totalDeposit > 0 && ageDays > 0) {
+        const aprValue = (apiPos.earnedUsd / apiPos.totalDeposit / ageDays) * 365 * 100
+        apr = aprValue.toFixed(2)
+      }
+
+      updatePositionMappingSyncData(dbPos.our_nft_mint, {
+        pnlUsd: apiPos.pnlUsd?.toFixed(4) || '0',
+        pnlPercent: apiPos.pnlUsdPercent?.toFixed(4) || '0',
+        apr,
+        bonusUsd: apiPos.bonusUsd?.toFixed(4) || '0',
+        earnedUsd: apiPos.earnedUsd?.toFixed(4) || '0',
+        liquidityUsd: apiPos.liquidityUsd?.toFixed(2) || '0',
+      })
+      synced++
+    } else {
+      // Position NOT in API open list — mark as closed
+      updatePositionMappingStatus(dbPos.target_nft_mint, 'closed')
+      closed++
+    }
+  }
+
+  return { synced, closed, total: apiPositions.length }
+}

+ 42 - 0
src/lib/db/queries.ts

@@ -23,6 +23,12 @@ export interface PositionMapping {
   tick_lower: number
   tick_upper: number
   status: string
+  pnl_usd: string | null
+  pnl_percent: string | null
+  apr: string | null
+  bonus_usd: string | null
+  earned_usd: string | null
+  liquidity_usd: string | null
   created_at: string
   updated_at: string
 }
@@ -188,6 +194,42 @@ export function deletePositionMapping(id: number) {
   return getDb().prepare('DELETE FROM position_mappings WHERE id = ?').run(id)
 }
 
+export function updatePositionMappingSyncData(
+  ourNftMint: string,
+  data: {
+    pnlUsd?: string
+    pnlPercent?: string
+    apr?: string
+    bonusUsd?: string
+    earnedUsd?: string
+    liquidityUsd?: string
+  },
+) {
+  return getDb()
+    .prepare(
+      `UPDATE position_mappings SET
+        pnl_usd = ?, pnl_percent = ?, apr = ?, bonus_usd = ?, earned_usd = ?, liquidity_usd = ?,
+        updated_at = datetime('now')
+       WHERE our_nft_mint = ?`,
+    )
+    .run(
+      data.pnlUsd || null,
+      data.pnlPercent || null,
+      data.apr || null,
+      data.bonusUsd || null,
+      data.earnedUsd || null,
+      data.liquidityUsd || null,
+      ourNftMint,
+    )
+}
+
+export function getActivePositionNftMints(): string[] {
+  const rows = getDb()
+    .prepare("SELECT our_nft_mint FROM position_mappings WHERE status = 'active' AND our_nft_mint IS NOT NULL")
+    .all() as { our_nft_mint: string }[]
+  return rows.map((r) => r.our_nft_mint)
+}
+
 // Copy History
 export function addCopyHistory(params: {
   targetAddress: string

+ 22 - 0
src/lib/db/schema.ts

@@ -65,4 +65,26 @@ export function initDb(db: Database.Database) {
   if (!colNames.has('referrer_mode')) {
     db.exec("ALTER TABLE watched_addresses ADD COLUMN referrer_mode TEXT NOT NULL DEFAULT 'self'")
   }
+
+  // Migration: add Byreal API sync fields to position_mappings
+  const posCols = db.pragma('table_info(position_mappings)') as { name: string }[]
+  const posColNames = new Set(posCols.map((c) => c.name))
+  if (!posColNames.has('pnl_usd')) {
+    db.exec('ALTER TABLE position_mappings ADD COLUMN pnl_usd TEXT')
+  }
+  if (!posColNames.has('pnl_percent')) {
+    db.exec('ALTER TABLE position_mappings ADD COLUMN pnl_percent TEXT')
+  }
+  if (!posColNames.has('apr')) {
+    db.exec('ALTER TABLE position_mappings ADD COLUMN apr TEXT')
+  }
+  if (!posColNames.has('bonus_usd')) {
+    db.exec('ALTER TABLE position_mappings ADD COLUMN bonus_usd TEXT')
+  }
+  if (!posColNames.has('earned_usd')) {
+    db.exec('ALTER TABLE position_mappings ADD COLUMN earned_usd TEXT')
+  }
+  if (!posColNames.has('liquidity_usd')) {
+    db.exec('ALTER TABLE position_mappings ADD COLUMN liquidity_usd TEXT')
+  }
 }