435 lines
12 KiB
Go
435 lines
12 KiB
Go
|
|
package fundmarket
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"net/http"
|
|||
|
|
"strconv"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/gothinkster/golang-gin-realworld-example-app/common"
|
|||
|
|
"github.com/gothinkster/golang-gin-realworld-example-app/models"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// GetProducts returns all fund market products
|
|||
|
|
func GetProducts(c *gin.Context) {
|
|||
|
|
db := common.GetDB()
|
|||
|
|
|
|||
|
|
// Query assets with their performance data
|
|||
|
|
// token_list=1: return all active assets (for dropdowns)
|
|||
|
|
// default: exclude stablecoins (Fund Market product listing)
|
|||
|
|
var assets []models.Asset
|
|||
|
|
query := db.Where("is_active = ?", true)
|
|||
|
|
if c.Query("token_list") != "1" {
|
|||
|
|
query = query.Where("token_role != ?", "stablecoin")
|
|||
|
|
}
|
|||
|
|
err := query.Find(&assets).Error
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|||
|
|
"success": false,
|
|||
|
|
"error": "Failed to fetch products",
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get performance data for each asset
|
|||
|
|
products := make([]models.ProductResponse, 0)
|
|||
|
|
for _, asset := range assets {
|
|||
|
|
var perf models.AssetPerformance
|
|||
|
|
db.Where("asset_id = ?", asset.ID).
|
|||
|
|
Order("snapshot_date DESC").
|
|||
|
|
First(&perf)
|
|||
|
|
|
|||
|
|
riskLabel := asset.RiskLabel
|
|||
|
|
if riskLabel == "" {
|
|||
|
|
riskLabel = "--"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
circulatingSupplyStr := "--"
|
|||
|
|
if perf.CirculatingSupply > 0 {
|
|||
|
|
circulatingSupplyStr = formatUSD(perf.CirculatingSupply)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
poolCapStr := "--"
|
|||
|
|
if asset.PoolCapUSD > 0 {
|
|||
|
|
poolCapStr = formatUSD(asset.PoolCapUSD)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
product := models.ProductResponse{
|
|||
|
|
ID: int(asset.ID),
|
|||
|
|
Name: asset.Name,
|
|||
|
|
TokenSymbol: asset.TokenSymbol,
|
|||
|
|
Decimals: asset.Decimals,
|
|||
|
|
ContractAddress: asset.ContractAddress,
|
|||
|
|
ChainID: asset.ChainID,
|
|||
|
|
TokenRole: asset.TokenRole,
|
|||
|
|
Category: asset.Category,
|
|||
|
|
CategoryColor: asset.CategoryColor,
|
|||
|
|
IconURL: asset.IconURL,
|
|||
|
|
YieldAPY: fmt.Sprintf("%.1f%%", asset.TargetAPY),
|
|||
|
|
PoolCap: poolCapStr,
|
|||
|
|
Risk: riskLabel,
|
|||
|
|
RiskLevel: asset.RiskLevel,
|
|||
|
|
CirculatingSupply: circulatingSupplyStr,
|
|||
|
|
PoolCapacityPercent: perf.PoolCapacityPercent,
|
|||
|
|
}
|
|||
|
|
products = append(products, product)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": products,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetStats returns fund market statistics
|
|||
|
|
func GetStats(c *gin.Context) {
|
|||
|
|
db := common.GetDB()
|
|||
|
|
|
|||
|
|
// Total Value Locked = sum of latest tvlusd across all active assets
|
|||
|
|
var totalTVL float64
|
|||
|
|
db.Raw(`
|
|||
|
|
SELECT COALESCE(SUM(ap.tvlusd), 0)
|
|||
|
|
FROM asset_performance ap
|
|||
|
|
INNER JOIN (
|
|||
|
|
SELECT asset_id, MAX(snapshot_date) AS latest
|
|||
|
|
FROM asset_performance
|
|||
|
|
GROUP BY asset_id
|
|||
|
|
) latest ON ap.asset_id = latest.asset_id AND ap.snapshot_date = latest.latest
|
|||
|
|
INNER JOIN assets a ON a.id = ap.asset_id AND a.is_active = true
|
|||
|
|
`).Scan(&totalTVL)
|
|||
|
|
|
|||
|
|
// Cumulative Yield = sum of cumulative_yield_usd from latest snapshots
|
|||
|
|
var totalYield float64
|
|||
|
|
db.Raw(`
|
|||
|
|
SELECT COALESCE(SUM(ap.cumulative_yield_usd), 0)
|
|||
|
|
FROM asset_performance ap
|
|||
|
|
INNER JOIN (
|
|||
|
|
SELECT asset_id, MAX(snapshot_date) AS latest
|
|||
|
|
FROM asset_performance
|
|||
|
|
GROUP BY asset_id
|
|||
|
|
) latest ON ap.asset_id = latest.asset_id AND ap.snapshot_date = latest.latest
|
|||
|
|
`).Scan(&totalYield)
|
|||
|
|
|
|||
|
|
stats := []models.StatsResponse{
|
|||
|
|
{
|
|||
|
|
Label: "Total Value Locked",
|
|||
|
|
Value: formatUSD(totalTVL),
|
|||
|
|
Change: "",
|
|||
|
|
IsPositive: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
Label: "Cumulative Yield",
|
|||
|
|
Value: formatUSD(totalYield),
|
|||
|
|
Change: "",
|
|||
|
|
IsPositive: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
Label: "Your Total Balance",
|
|||
|
|
Value: "--",
|
|||
|
|
Change: "",
|
|||
|
|
IsPositive: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
Label: "Your Total Earning",
|
|||
|
|
Value: "--",
|
|||
|
|
Change: "",
|
|||
|
|
IsPositive: true,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": stats,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetProductByID returns detailed product information by ID
|
|||
|
|
func GetProductByID(c *gin.Context) {
|
|||
|
|
db := common.GetDB()
|
|||
|
|
id := c.Param("id")
|
|||
|
|
|
|||
|
|
var asset models.Asset
|
|||
|
|
err := db.Where("id = ? AND is_active = ?", id, true).First(&asset).Error
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|||
|
|
"success": false,
|
|||
|
|
"error": "Product not found",
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get performance data
|
|||
|
|
var perf models.AssetPerformance
|
|||
|
|
db.Where("asset_id = ?", asset.ID).
|
|||
|
|
Order("snapshot_date DESC").
|
|||
|
|
First(&perf)
|
|||
|
|
|
|||
|
|
// Calculate volume change vs 7-day average (excluding today)
|
|||
|
|
var avg7dVolume float64
|
|||
|
|
db.Raw(`
|
|||
|
|
SELECT AVG(volume_24h_usd) FROM (
|
|||
|
|
SELECT volume_24h_usd FROM asset_performance
|
|||
|
|
WHERE asset_id = ? AND snapshot_date < CURDATE()
|
|||
|
|
ORDER BY snapshot_date DESC
|
|||
|
|
LIMIT 7
|
|||
|
|
) t
|
|||
|
|
`, asset.ID).Scan(&avg7dVolume)
|
|||
|
|
volumeChangeVsAvg := 0.0
|
|||
|
|
if avg7dVolume > 0 {
|
|||
|
|
volumeChangeVsAvg = (perf.Volume24hUSD - avg7dVolume) / avg7dVolume * 100
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get custody info
|
|||
|
|
var custody models.AssetCustody
|
|||
|
|
hasCustody := db.Where("asset_id = ?", asset.ID).First(&custody).Error == nil
|
|||
|
|
|
|||
|
|
// Get audit reports
|
|||
|
|
var auditReports []models.AssetAuditReport
|
|||
|
|
db.Where("asset_id = ? AND is_active = ?", asset.ID, true).
|
|||
|
|
Order("report_date DESC").
|
|||
|
|
Limit(10).
|
|||
|
|
Find(&auditReports)
|
|||
|
|
|
|||
|
|
// Get product links
|
|||
|
|
var productLinks []models.ProductLink
|
|||
|
|
db.Where("asset_id = ? AND is_active = ?", asset.ID, true).
|
|||
|
|
Order("display_order ASC, id ASC").
|
|||
|
|
Find(&productLinks)
|
|||
|
|
|
|||
|
|
// Build response
|
|||
|
|
detail := models.ProductDetailResponse{
|
|||
|
|
// Basic Info
|
|||
|
|
ID: int(asset.ID),
|
|||
|
|
AssetCode: asset.AssetCode,
|
|||
|
|
Name: asset.Name,
|
|||
|
|
Subtitle: asset.Subtitle,
|
|||
|
|
Description: asset.Description,
|
|||
|
|
TokenSymbol: asset.TokenSymbol,
|
|||
|
|
Decimals: asset.Decimals,
|
|||
|
|
|
|||
|
|
// Investment Parameters
|
|||
|
|
UnderlyingAssets: asset.UnderlyingAssets,
|
|||
|
|
PoolCapUSD: asset.PoolCapUSD,
|
|||
|
|
RiskLevel: asset.RiskLevel,
|
|||
|
|
RiskLabel: asset.RiskLabel,
|
|||
|
|
TargetAPY: asset.TargetAPY,
|
|||
|
|
|
|||
|
|
// Contract Info
|
|||
|
|
ContractAddress: asset.ContractAddress,
|
|||
|
|
ChainID: asset.ChainID,
|
|||
|
|
|
|||
|
|
// Display Info
|
|||
|
|
Category: asset.Category,
|
|||
|
|
CategoryColor: asset.CategoryColor,
|
|||
|
|
IconURL: asset.IconURL,
|
|||
|
|
|
|||
|
|
// Performance Data
|
|||
|
|
CurrentAPY: perf.CurrentAPY,
|
|||
|
|
TVLUSD: perf.TVLUSD,
|
|||
|
|
Volume24hUSD: perf.Volume24hUSD,
|
|||
|
|
VolumeChangeVsAvg: volumeChangeVsAvg,
|
|||
|
|
CirculatingSupply: perf.CirculatingSupply,
|
|||
|
|
PoolCapacityPercent: perf.PoolCapacityPercent,
|
|||
|
|
CurrentPrice: perf.YTPrice,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add custody info if exists
|
|||
|
|
if hasCustody {
|
|||
|
|
lastAuditDate := ""
|
|||
|
|
if custody.LastAuditDate.Time != nil {
|
|||
|
|
lastAuditDate = custody.LastAuditDate.Time.Format("02 Jan 2006")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var additionalInfo map[string]interface{}
|
|||
|
|
if custody.AdditionalInfo != "" {
|
|||
|
|
_ = json.Unmarshal([]byte(custody.AdditionalInfo), &additionalInfo)
|
|||
|
|
}
|
|||
|
|
if additionalInfo == nil {
|
|||
|
|
additionalInfo = map[string]interface{}{}
|
|||
|
|
}
|
|||
|
|
// 从 maturity_date 动态计算 days_remaining,不再依赖存储值
|
|||
|
|
if maturityStr, ok := additionalInfo["maturity_date"].(string); ok && maturityStr != "" {
|
|||
|
|
if maturityDate, err := time.Parse("2006-01-02", maturityStr); err == nil {
|
|||
|
|
days := int(time.Until(maturityDate).Hours() / 24)
|
|||
|
|
additionalInfo["days_remaining"] = days
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
delete(additionalInfo, "days_remaining")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
detail.Custody = &models.CustodyInfo{
|
|||
|
|
CustodianName: custody.CustodianName,
|
|||
|
|
CustodyType: custody.CustodyType,
|
|||
|
|
CustodyLocation: custody.CustodyLocation,
|
|||
|
|
AuditorName: custody.AuditorName,
|
|||
|
|
LastAuditDate: lastAuditDate,
|
|||
|
|
AuditReportURL: custody.AuditReportURL,
|
|||
|
|
AdditionalInfo: additionalInfo,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add audit reports
|
|||
|
|
detail.AuditReports = make([]models.AuditReportInfo, 0, len(auditReports))
|
|||
|
|
for _, report := range auditReports {
|
|||
|
|
detail.AuditReports = append(detail.AuditReports, models.AuditReportInfo{
|
|||
|
|
ReportType: report.ReportType,
|
|||
|
|
ReportTitle: report.ReportTitle,
|
|||
|
|
ReportDate: report.ReportDate.Format("02 Jan 2006"),
|
|||
|
|
AuditorName: report.AuditorName,
|
|||
|
|
Summary: report.Summary,
|
|||
|
|
ReportURL: report.ReportURL,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add product links
|
|||
|
|
detail.ProductLinks = make([]models.ProductLinkInfo, 0, len(productLinks))
|
|||
|
|
for _, link := range productLinks {
|
|||
|
|
detail.ProductLinks = append(detail.ProductLinks, models.ProductLinkInfo{
|
|||
|
|
LinkText: link.LinkText,
|
|||
|
|
LinkURL: link.LinkURL,
|
|||
|
|
Description: link.Description,
|
|||
|
|
DisplayArea: link.DisplayArea,
|
|||
|
|
DisplayOrder: link.DisplayOrder,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": detail,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetProductHistory returns daily APY/price history for a product (one point per day)
|
|||
|
|
func GetProductHistory(c *gin.Context) {
|
|||
|
|
db := common.GetDB()
|
|||
|
|
id := c.Param("id")
|
|||
|
|
|
|||
|
|
// Pick the last snapshot of each calendar day (UTC), up to 30 days
|
|||
|
|
var snapshots []models.APYSnapshot
|
|||
|
|
if err := db.Raw(`
|
|||
|
|
SELECT ap.* FROM apy_snapshots ap
|
|||
|
|
INNER JOIN (
|
|||
|
|
SELECT DATE(snapshot_time) AS snap_date, MAX(snapshot_time) AS latest
|
|||
|
|
FROM apy_snapshots
|
|||
|
|
WHERE asset_id = ?
|
|||
|
|
GROUP BY DATE(snapshot_time)
|
|||
|
|
ORDER BY snap_date DESC
|
|||
|
|
LIMIT 30
|
|||
|
|
) d ON DATE(ap.snapshot_time) = d.snap_date AND ap.snapshot_time = d.latest
|
|||
|
|
WHERE ap.asset_id = ?
|
|||
|
|
ORDER BY ap.snapshot_time ASC
|
|||
|
|
`, id, id).Scan(&snapshots).Error; err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|||
|
|
"success": false,
|
|||
|
|
"error": "Failed to fetch history",
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type HistoryPoint struct {
|
|||
|
|
Time string `json:"time"`
|
|||
|
|
APY float64 `json:"apy"`
|
|||
|
|
Price float64 `json:"price"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
points := make([]HistoryPoint, len(snapshots))
|
|||
|
|
for i, s := range snapshots {
|
|||
|
|
points[i] = HistoryPoint{
|
|||
|
|
Time: s.SnapshotTime.UTC().Format(time.RFC3339),
|
|||
|
|
APY: s.APYValue,
|
|||
|
|
Price: s.Price,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": points,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Helper function to format USD values
|
|||
|
|
func formatUSD(value float64) string {
|
|||
|
|
if value >= 1000000 {
|
|||
|
|
return fmt.Sprintf("$%.1fM", value/1000000)
|
|||
|
|
} else if value >= 1000 {
|
|||
|
|
return fmt.Sprintf("$%.1fK", value/1000)
|
|||
|
|
}
|
|||
|
|
return fmt.Sprintf("$%.2f", value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetDailyReturns returns daily net return data for a product by month.
|
|||
|
|
// Query params: year (int), month (int). Defaults to current month.
|
|||
|
|
func GetDailyReturns(c *gin.Context) {
|
|||
|
|
db := common.GetDB()
|
|||
|
|
id := c.Param("id")
|
|||
|
|
|
|||
|
|
now := time.Now().UTC()
|
|||
|
|
year := now.Year()
|
|||
|
|
month := int(now.Month())
|
|||
|
|
|
|||
|
|
if y, err := strconv.Atoi(c.Query("year")); err == nil {
|
|||
|
|
year = y
|
|||
|
|
}
|
|||
|
|
if m, err := strconv.Atoi(c.Query("month")); err == nil && m >= 1 && m <= 12 {
|
|||
|
|
month = m
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
startDate := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
|||
|
|
endDate := startDate.AddDate(0, 1, 0)
|
|||
|
|
// Include one day before month start for delta calculation
|
|||
|
|
queryStart := startDate.AddDate(0, 0, -1)
|
|||
|
|
|
|||
|
|
var perfs []models.AssetPerformance
|
|||
|
|
db.Where("asset_id = ? AND snapshot_date >= ? AND snapshot_date < ?", id, queryStart, endDate).
|
|||
|
|
Order("snapshot_date ASC").
|
|||
|
|
Find(&perfs)
|
|||
|
|
|
|||
|
|
// Map date string -> performance
|
|||
|
|
type perfEntry struct {
|
|||
|
|
ytPrice float64
|
|||
|
|
hasData bool
|
|||
|
|
}
|
|||
|
|
perfMap := make(map[string]perfEntry)
|
|||
|
|
for _, p := range perfs {
|
|||
|
|
perfMap[p.SnapshotDate.Format("2006-01-02")] = perfEntry{ytPrice: p.YTPrice, hasData: true}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type DayReturn struct {
|
|||
|
|
Date string `json:"date"`
|
|||
|
|
YTPrice float64 `json:"ytPrice"`
|
|||
|
|
DailyReturn float64 `json:"dailyReturn"`
|
|||
|
|
HasData bool `json:"hasData"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
today := now.Format("2006-01-02")
|
|||
|
|
var results []DayReturn
|
|||
|
|
for d := startDate; d.Before(endDate); d = d.AddDate(0, 0, 1) {
|
|||
|
|
dateStr := d.Format("2006-01-02")
|
|||
|
|
// Skip future days
|
|||
|
|
if dateStr > today {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
cur := perfMap[dateStr]
|
|||
|
|
prev := perfMap[d.AddDate(0, 0, -1).Format("2006-01-02")]
|
|||
|
|
|
|||
|
|
dailyReturn := 0.0
|
|||
|
|
if cur.hasData && prev.hasData && prev.ytPrice > 0 {
|
|||
|
|
dailyReturn = (cur.ytPrice - prev.ytPrice) / prev.ytPrice * 100
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
results = append(results, DayReturn{
|
|||
|
|
Date: dateStr,
|
|||
|
|
YTPrice: cur.ytPrice,
|
|||
|
|
DailyReturn: dailyReturn,
|
|||
|
|
HasData: cur.hasData,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, gin.H{
|
|||
|
|
"success": true,
|
|||
|
|
"data": results,
|
|||
|
|
})
|
|||
|
|
}
|