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