Skip to content

src/coinprices/coinprices.cpp

Namespaces

Name
sgns

Functions

Name
OUTCOME_CPP_DEFINE_CATEGORY_3(sgns , CoinGeckoPriceRetriever::PriceError , e )
std::string decodeChunkedTransfer(const std::string & chunkedData)

Functions Documentation

function OUTCOME_CPP_DEFINE_CATEGORY_3

OUTCOME_CPP_DEFINE_CATEGORY_3(
    sgns ,
    CoinGeckoPriceRetriever::PriceError ,
    e 
)

function decodeChunkedTransfer

std::string decodeChunkedTransfer(
    const std::string & chunkedData
)

Source code

#include <fmt/format.h>
#include "coinprices.hpp"
#include <rapidjson/document.h>
#include "FileManager.hpp"

OUTCOME_CPP_DEFINE_CATEGORY_3( sgns, CoinGeckoPriceRetriever::PriceError, e )
{
    switch ( e )
    {
        case sgns::CoinGeckoPriceRetriever::PriceError::EmptyInput:
            return "Empty Input";
        case sgns::CoinGeckoPriceRetriever::PriceError::NetworkError:
            return "Network Error";
        case sgns::CoinGeckoPriceRetriever::PriceError::JsonParseError:
            return "Json Parse Error";
        case sgns::CoinGeckoPriceRetriever::PriceError::NoDataFound:
            return "No Data";
        case sgns::CoinGeckoPriceRetriever::PriceError::RateLimitExceeded:
            return "Rate limit exceeded";
        case sgns::CoinGeckoPriceRetriever::PriceError::DateTooOld:
            return "Date exceeds year limit";
    }
    return "Unknown error";
}

namespace sgns
{
    CoinGeckoPriceRetriever::CoinGeckoPriceRetriever() {}

    std::string decodeChunkedTransfer( const std::string &chunkedData )
    {
        std::string        result;
        std::istringstream iss( chunkedData );
        std::string        line;

        while ( std::getline( iss, line ) )
        {
            // Trim carriage returns if present
            if ( !line.empty() && line.back() == '\r' )
            {
                line.pop_back();
            }

            // Skip empty lines
            if ( line.empty() )
            {
                continue;
            }

            // Try to parse as a hex chunk length
            std::istringstream hexStream( line );
            unsigned int       chunkSize;
            if ( hexStream >> std::hex >> chunkSize )
            {
                // If chunk size is 0, we're done
                if ( chunkSize == 0 )
                {
                    break;
                }

                // Read exactly chunkSize bytes
                char *buffer = new char[chunkSize + 1];
                iss.read( buffer, chunkSize );
                buffer[chunkSize] = '\0';

                // Append to result
                result.append( buffer, chunkSize );
                delete[] buffer;

                // Skip the trailing CRLF after the chunk
                iss.ignore( 2 );
            }
            else
            {
                // Not a valid hex chunk size, maybe part of the actual data
                result += line + "\n";
            }
        }

        return result;
    }

    // Helper method to format Unix timestamp to DD-MM-YYYY for CoinGecko API
    std::string CoinGeckoPriceRetriever::formatDate( int64_t timestamp, bool includeTime )
    {
        // Convert milliseconds to seconds if needed
        time_t time = ( timestamp > 9999999999 ) ? timestamp / 1000 : timestamp;

        std::tm           tm = *std::gmtime( &time );
        std::stringstream ss;

        if ( !includeTime )
        {
            // Original DD-MM-YYYY format for CoinGecko API
            ss << std::setfill( '0' ) << std::setw( 2 ) << tm.tm_mday << "-" << std::setw( 2 ) << ( tm.tm_mon + 1 )
               << "-" << ( tm.tm_year + 1900 );
        }
        else
        {
            // Full date and time format: YYYY-MM-DD HH:MM:SS.mmm
            int milliseconds = 0;
            if ( timestamp > 9999999999 )
            {
                milliseconds = timestamp % 1000;
            }

            ss << ( tm.tm_year + 1900 ) << "-" << std::setfill( '0' ) << std::setw( 2 ) << ( tm.tm_mon + 1 ) << "-"
               << std::setfill( '0' ) << std::setw( 2 ) << tm.tm_mday << " " << std::setfill( '0' ) << std::setw( 2 )
               << tm.tm_hour << ":" << std::setfill( '0' ) << std::setw( 2 ) << tm.tm_min << ":" << std::setfill( '0' )
               << std::setw( 2 ) << tm.tm_sec;

            if ( milliseconds > 0 )
            {
                ss << "." << std::setfill( '0' ) << std::setw( 3 ) << milliseconds;
            }
        }

        return ss.str();
    }

    // Get current price with boost::outcome
    outcome::result<std::map<std::string, double>> CoinGeckoPriceRetriever::getCurrentPrices(
        const std::vector<std::string> &tokenIds )
    {
        std::map<std::string, double> prices;

        if ( tokenIds.empty() )
        {
            return outcome::failure( PriceError::EmptyInput );
        }

        try
        {
            // Join the token IDs with commas for the API request
            std::string tokenIdsList;
            for ( size_t i = 0; i < tokenIds.size(); i++ )
            {
                tokenIdsList += tokenIds[i];
                if ( i < tokenIds.size() - 1 )
                {
                    tokenIdsList += ",";
                }
            }
            m_logger->debug( "Token IDS: {}", tokenIdsList );

            // Create HTTP request
            auto                                   ioc      = std::make_shared<boost::asio::io_context>();
            boost::asio::io_context::executor_type executor = ioc->get_executor();
            boost::asio::executor_work_guard<boost::asio::io_context::executor_type> workGuard( executor );
            std::string url = "https://api.coingecko.com/api/v3/simple/price?ids=" + tokenIdsList +
                              "&vs_currencies=usd";
            FileManager::GetInstance().InitializeSingletons();
            std::string res;
            bool        requestSucceeded = true;

            auto result = FileManager::GetInstance().LoadASync(
                url,
                false,
                false,
                ioc,
                [this,
                 &res]( outcome::result<
                        std::shared_ptr<std::pair<std::vector<std::string>, std::vector<std::vector<char>>>>> buffers )
                {
                    if ( buffers )
                    {
                        res = std::string( buffers.value()->second[0].begin(), buffers.value()->second[0].end() );
                    }
                    else
                    {
                        m_logger->error( "Failed to get coin price: {}", buffers.error().message() );
                    }
                },
                "file" );

            ioc->run();

            if ( !requestSucceeded )
            {
                return outcome::failure( PriceError::NetworkError );
            }

            m_logger->debug( "Res Is: {}", res );
            std::string json_str = decodeChunkedTransfer( res );

            // Parse the JSON response
            rapidjson::Document document;
            document.Parse( json_str.c_str() );

            if ( document.HasParseError() )
            {
                m_logger->error( "JSON Parse Error: {}", fmt::underlying( document.GetParseError() ) );
                return outcome::failure( PriceError::JsonParseError );
            }

            // Check if the response contains an error message about rate limits
            if ( document.IsObject() && document.HasMember( "status" ) && document["status"].IsObject() &&
                 document["status"].HasMember( "error_code" ) )
            {
                int error_code = document["status"]["error_code"].GetInt();
                if ( error_code == 429 )
                {
                    return outcome::failure( PriceError::RateLimitExceeded );
                }
            }

            // Extract the prices for each token
            bool foundAnyPrice = false;
            for ( const auto &tokenId : tokenIds )
            {
                if ( document.HasMember( tokenId.c_str() ) && document[tokenId.c_str()].HasMember( "usd" ) )
                {
                    prices[tokenId] = document[tokenId.c_str()]["usd"].GetDouble();
                    foundAnyPrice   = true;
                }
            }

            if ( !foundAnyPrice )
            {
                return outcome::failure( PriceError::NoDataFound );
            }
        }
        catch ( const std::exception &e )
        {
            m_logger->error( "Error getting current prices: {}", e.what() );
            return outcome::failure( PriceError::NetworkError );
        }

        return outcome::success( prices );
    }

    // Get historical prices for a list of timestamps with boost::outcome
    outcome::result<std::map<std::string, std::map<int64_t, double>>> CoinGeckoPriceRetriever::getHistoricalPrices(
        const std::vector<std::string> &tokenIds,
        const std::vector<int64_t>     &timestamps )
    {
        std::map<std::string, std::map<int64_t, double>> allPrices;

        if ( tokenIds.empty() || timestamps.empty() )
        {
            return outcome::failure( PriceError::EmptyInput );
        }

        try
        {
            // Get current timestamp for 365-day limit checking
            time_t now        = std::time( nullptr );
            time_t oneYearAgo = now - ( 365 * 24 * 60 * 60 );

            bool allTooOld = true;

            // Process each timestamp for all tokens
            for ( int64_t timestamp : timestamps )
            {
                // Convert to seconds if in milliseconds
                time_t timestampSec = ( timestamp > 9999999999 ) ? timestamp / 1000 : timestamp;

                // Skip timestamps older than 1 year due to CoinGecko restrictions
                if ( timestampSec < oneYearAgo )
                {
                    m_logger->error( "Skipping {}", formatDate( timestamp ) );
                    continue;
                }

                allTooOld                 = false;
                std::string formattedDate = formatDate( timestamp );

                // Process each token for this timestamp
                for ( const auto &tokenId : tokenIds )
                {
                    // Create HTTP request
                    auto                                   ioc      = std::make_shared<boost::asio::io_context>();
                    boost::asio::io_context::executor_type executor = ioc->get_executor();
                    boost::asio::executor_work_guard<boost::asio::io_context::executor_type> workGuard( executor );
                    std::string url = "https://api.coingecko.com/api/v3/coins/" + tokenId +
                                      "/history?date=" + formattedDate;
                    FileManager::GetInstance().InitializeSingletons();
                    std::string res;
                    bool        requestSucceeded = true;

                    auto result = FileManager::GetInstance().LoadASync(
                        url,
                        false,
                        false,
                        ioc,
                        [this,
                         &res]( outcome::result<std::shared_ptr<
                                    std::pair<std::vector<std::string>, std::vector<std::vector<char>>>>> buffers )
                        {
                            if ( buffers )
                            {
                                res = std::string( buffers.value()->second[0].begin(),
                                                   buffers.value()->second[0].end() );
                            }
                            else
                            {
                                m_logger->error( "Failed to get coin historical price: {}", buffers.error().message() );
                            }
                        },
                        "file" );

                    ioc->run();

                    if ( !requestSucceeded )
                    {
                        continue; // Try the next token instead of failing completely
                    }

                    m_logger->debug( "Res Is: {}", res );
                    std::string json_str = decodeChunkedTransfer( res );

                    // Parse the JSON response
                    rapidjson::Document document;
                    document.Parse( json_str.c_str() );

                    if ( document.HasParseError() )
                    {
                        m_logger->error( "JSON Parse Error: {}", fmt::underlying( document.GetParseError() ) );
                        continue; // Try the next token
                    }

                    // Check if the response contains an error message about rate limits
                    if ( document.IsObject() && document.HasMember( "status" ) && document["status"].IsObject() &&
                         document["status"].HasMember( "error_code" ) )
                    {
                        int error_code = document["status"]["error_code"].GetInt();
                        if ( error_code == 429 )
                        {
                            return outcome::failure( PriceError::RateLimitExceeded );
                        }
                    }

                    // Extract the price
                    if ( document.HasMember( "market_data" ) && document["market_data"].HasMember( "current_price" ) &&
                         document["market_data"]["current_price"].HasMember( "usd" ) )
                    {
                        double price                  = document["market_data"]["current_price"]["usd"].GetDouble();
                        allPrices[tokenId][timestamp] = price;
                    }
                    else
                    {
                        m_logger->error( "No price data found for {} on {}", tokenId, formattedDate );
                    }

                    // Respect rate limits between tokens
                    if ( &tokenId != &tokenIds.back() )
                    {
                        std::this_thread::sleep_for( std::chrono::milliseconds( 1100 ) );
                    }
                }
            }

            if ( allTooOld )
            {
                return outcome::failure( PriceError::DateTooOld );
            }

            if ( allPrices.empty() )
            {
                return outcome::failure( PriceError::NoDataFound );
            }
        }
        catch ( const std::exception &e )
        {
            m_logger->error( "Error getting historical prices: {}", e.what() );
            return outcome::failure( PriceError::NetworkError );
        }

        return outcome::success( allPrices );
    }

    // Get historical price range with boost::outcome
    outcome::result<std::map<std::string, std::map<int64_t, double>>> CoinGeckoPriceRetriever::getHistoricalPriceRange(
        const std::vector<std::string> &tokenIds,
        int64_t                         from,
        int64_t                         to )
    {
        std::map<std::string, std::map<int64_t, double>> allPrices;

        if ( tokenIds.empty() )
        {
            return outcome::failure( PriceError::EmptyInput );
        }

        try
        {
            // Make sure we don't exceed the 365-day limit for free tier
            time_t now        = std::time( nullptr );
            time_t oneYearAgo = now - ( 365 * 24 * 60 * 60 );

            // Convert from milliseconds to seconds if needed
            time_t fromSec = ( from > 9999999999 ) ? from / 1000 : from;

            bool dateTooOld = false;
            if ( fromSec < oneYearAgo )
            {
                fromSec    = oneYearAgo;
                dateTooOld = true;
            }

            // Convert timestamps to Unix timestamps in seconds
            int fromUnix = static_cast<int>( fromSec );
            int toUnix   = static_cast<int>( ( to > 9999999999 ) ? to / 1000 : to );

            // Make sure from is before to (could happen with timestamp conversion issues)
            if ( fromUnix >= toUnix )
            {
                m_logger->error( "Error: 'from' date must be before 'to' date" );
                return outcome::failure( PriceError::EmptyInput ); // Using EmptyInput for invalid date range
            }

            // Process each token
            for ( const auto &tokenId : tokenIds )
            {
                m_logger->debug( "CoinGecko request for {}", tokenId );

                // Create HTTP request
                auto                                   ioc      = std::make_shared<boost::asio::io_context>();
                boost::asio::io_context::executor_type executor = ioc->get_executor();
                boost::asio::executor_work_guard<boost::asio::io_context::executor_type> workGuard( executor );
                std::string url = "https://api.coingecko.com/api/v3/coins/" + tokenId +
                                  "/market_chart/range?vs_currency=usd&from=" + std::to_string( fromUnix ) +
                                  "&to=" + std::to_string( toUnix );
                FileManager::GetInstance().InitializeSingletons();
                std::string res;
                bool        requestSucceeded = true;

                auto result = FileManager::GetInstance().LoadASync(
                    url,
                    false,
                    false,
                    ioc,
                    [this, &res]( outcome::result<std::shared_ptr<
                                      std::pair<std::vector<std::string>, std::vector<std::vector<char>>>>> buffers )
                    {
                        if ( buffers )
                        {
                            res = std::string( buffers.value()->second[0].begin(), buffers.value()->second[0].end() );
                        }
                        else
                        {
                            m_logger->error( "Failed to get historical range coin price: {}",
                                             buffers.error().message() );
                        }
                    },
                    "file" );

                ioc->run();

                if ( !requestSucceeded )
                {
                    continue; // Try the next token instead of failing completely
                }

                m_logger->debug( "Res Is: {}", res );
                std::string json_str = decodeChunkedTransfer( res );

                // Parse the JSON response
                rapidjson::Document document;
                document.Parse( json_str.c_str() );

                if ( document.HasParseError() )
                {
                    m_logger->error( "JSON Parse Error: {}", fmt::underlying( document.GetParseError() ) );
                    continue; // Try the next token
                }

                if ( !document.IsObject() )
                {
                    m_logger->error( "JSON is not an object!" );
                    continue; // Try the next token
                }

                // Check if the response contains an error message about rate limits
                if ( document.HasMember( "status" ) && document["status"].IsObject() &&
                     document["status"].HasMember( "error_code" ) )
                {
                    int error_code = document["status"]["error_code"].GetInt();
                    if ( error_code == 429 )
                    {
                        return outcome::failure( PriceError::RateLimitExceeded );
                    }
                }

                // Extract the prices array
                if ( document.HasMember( "prices" ) && document["prices"].IsArray() )
                {
                    const auto &pricesArray = document["prices"];

                    for ( rapidjson::SizeType i = 0; i < pricesArray.Size(); i++ )
                    {
                        if ( pricesArray[i].IsArray() && pricesArray[i].Size() >= 2 )
                        {
                            int64_t timestamp             = static_cast<int64_t>( pricesArray[i][0].GetDouble() );
                            double  price                 = pricesArray[i][1].GetDouble();
                            allPrices[tokenId][timestamp] = price;
                        }
                    }
                }
                else
                {
                    m_logger->error( "No price data found for {} in the specified range", tokenId );
                }

                // Respect rate limits between tokens
                if ( &tokenId != &tokenIds.back() )
                {
                    std::this_thread::sleep_for( std::chrono::milliseconds( 1100 ) );
                }
            }

            if ( allPrices.empty() )
            {
                return outcome::failure( PriceError::NoDataFound );
            }

            // If date was too old and we adjusted it, we should indicate this to the caller
            if ( dateTooOld )
            {
                // We still return the data, but with a warning via the PriceError
                // Ideally this would be implemented as a custom "success with info" type in outcome
                m_logger->warn( "Some dates were too old and were adjusted to one year ago limit" );
            }
        }
        catch ( const std::exception &e )
        {
            m_logger->error( "Error getting historical price range: {}", e.what() );
            return outcome::failure( PriceError::NetworkError );
        }

        return outcome::success( allPrices );
    }
}

Updated on 2026-03-04 at 13:10:44 -0800