diff --git a/Tzkt.Api/Controllers/CommitmentsController.cs b/Tzkt.Api/Controllers/CommitmentsController.cs index 95d401353..75b3f6c8c 100644 --- a/Tzkt.Api/Controllers/CommitmentsController.cs +++ b/Tzkt.Api/Controllers/CommitmentsController.cs @@ -46,7 +46,7 @@ public Task Get([BlindedAddress] string address) /// Filters commitments by activation level /// Filters commitments by activated balance /// Specify comma-separated list of fields to include into response or leave it undefined to return full object. If you select single field, response will be an array of values in both `.fields` and `.values` modes. - /// Sorts delegators by specified field. Supported fields: `id` (default), `balance`, `firstActivity`, `lastActivity`, `numTransactions`, `numContracts`. + /// Sorts delegators by specified field. Supported fields: `id` (default), `balance`, `activationLevel`. /// Specifies which or how many items should be skipped /// Maximum number of items to return /// diff --git a/Tzkt.Api/Controllers/StatisticsController.cs b/Tzkt.Api/Controllers/StatisticsController.cs new file mode 100644 index 000000000..d093578bc --- /dev/null +++ b/Tzkt.Api/Controllers/StatisticsController.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +using Tzkt.Api.Models; +using Tzkt.Api.Repositories; + +namespace Tzkt.Api.Controllers +{ + [ApiController] + [Route("v1/statistics")] + public class StatisticsController : ControllerBase + { + private readonly StatisticsRepository Statistics; + + public StatisticsController(StatisticsRepository statistics) + { + Statistics = statistics; + } + + /// + /// Get statistics + /// + /// + /// Returns a list of end-of-block statistics. + /// + /// Filters statistics by level. + /// Filters statistics by timestamp. + /// Specify comma-separated list of fields to include into response or leave it undefined to return full object. If you select single field, response will be an array of values in both `.fields` and `.values` modes. + /// Sorts delegators by specified field. Supported fields: `id` (default), `level`, `cycle`, `date`. + /// Specifies which or how many items should be skipped + /// Maximum number of items to return + /// Comma-separated list of ticker symbols to inject historical prices into response + /// + [HttpGet] + public async Task>> Get( + Int32Parameter level, + TimestampParameter timestamp, + SelectParameter select, + SortParameter sort, + OffsetParameter offset, + [Range(0, 10000)] int limit = 100, + Symbols quote = Symbols.None) + { + #region validate + if (sort != null && !sort.Validate("id", "level", "cycle", "date")) + return new BadRequest($"{nameof(sort)}", "Sorting by the specified field is not allowed."); + #endregion + + if (select == null) + return Ok(await Statistics.Get(StatisticsPeriod.None, null, level, timestamp, null, sort, offset, limit, quote)); + + if (select.Values != null) + { + if (select.Values.Length == 1) + return Ok(await Statistics.Get(StatisticsPeriod.None, null, level, timestamp, null, sort, offset, limit, select.Values[0], quote)); + else + return Ok(await Statistics.Get(StatisticsPeriod.None, null, level, timestamp, null, sort, offset, limit, select.Values, quote)); + } + else + { + if (select.Fields.Length == 1) + return Ok(await Statistics.Get(StatisticsPeriod.None, null, level, timestamp, null, sort, offset, limit, select.Fields[0], quote)); + else + { + return Ok(new SelectionResponse + { + Cols = select.Fields, + Rows = await Statistics.Get(StatisticsPeriod.None, null, level, timestamp, null, sort, offset, limit, select.Fields, quote) + }); + } + } + } + + /// + /// Get daily statistics + /// + /// + /// Returns a list of end-of-day statistics. + /// + /// Filters statistics by date. + /// Specify comma-separated list of fields to include into response or leave it undefined to return full object. If you select single field, response will be an array of values in both `.fields` and `.values` modes. + /// Sorts delegators by specified field. Supported fields: `id` (default), `level`, `cycle`, `date`. + /// Specifies which or how many items should be skipped + /// Maximum number of items to return + /// Comma-separated list of ticker symbols to inject historical prices into response + /// + [HttpGet("daily")] + public async Task>> GetDaily( + DateTimeParameter date, + SelectParameter select, + SortParameter sort, + OffsetParameter offset, + [Range(0, 10000)] int limit = 100, + Symbols quote = Symbols.None) + { + #region validate + if (sort != null && !sort.Validate("id", "level", "cycle", "date")) + return new BadRequest($"{nameof(sort)}", "Sorting by the specified field is not allowed."); + #endregion + + if (select == null) + return Ok(await Statistics.Get(StatisticsPeriod.Daily, null, null, null, date, sort, offset, limit, quote)); + + if (select.Values != null) + { + if (select.Values.Length == 1) + return Ok(await Statistics.Get(StatisticsPeriod.Daily, null, null, null, date, sort, offset, limit, select.Values[0], quote)); + else + return Ok(await Statistics.Get(StatisticsPeriod.Daily, null, null, null, date, sort, offset, limit, select.Values, quote)); + } + else + { + if (select.Fields.Length == 1) + return Ok(await Statistics.Get(StatisticsPeriod.Daily, null, null, null, date, sort, offset, limit, select.Fields[0], quote)); + else + { + return Ok(new SelectionResponse + { + Cols = select.Fields, + Rows = await Statistics.Get(StatisticsPeriod.Daily, null, null, null, date, sort, offset, limit, select.Fields, quote) + }); + } + } + } + + /// + /// Get cyclic statistics + /// + /// + /// Returns a list of end-of-cycle statistics. + /// + /// Filters statistics by cycle. + /// Specify comma-separated list of fields to include into response or leave it undefined to return full object. If you select single field, response will be an array of values in both `.fields` and `.values` modes. + /// Sorts delegators by specified field. Supported fields: `id` (default), `level`, `cycle`, `date`. + /// Specifies which or how many items should be skipped + /// Maximum number of items to return + /// Comma-separated list of ticker symbols to inject historical prices into response + /// + [HttpGet("cyclic")] + public async Task>> GetCycles( + Int32Parameter cycle, + SelectParameter select, + SortParameter sort, + OffsetParameter offset, + [Range(0, 10000)] int limit = 100, + Symbols quote = Symbols.None) + { + #region validate + if (sort != null && !sort.Validate("id", "level", "cycle", "date")) + return new BadRequest($"{nameof(sort)}", "Sorting by the specified field is not allowed."); + #endregion + + if (select == null) + return Ok(await Statistics.Get(StatisticsPeriod.Cyclic, cycle, null, null, null, sort, offset, limit, quote)); + + if (select.Values != null) + { + if (select.Values.Length == 1) + return Ok(await Statistics.Get(StatisticsPeriod.Cyclic, cycle, null, null, null, sort, offset, limit, select.Values[0], quote)); + else + return Ok(await Statistics.Get(StatisticsPeriod.Cyclic, cycle, null, null, null, sort, offset, limit, select.Values, quote)); + } + else + { + if (select.Fields.Length == 1) + return Ok(await Statistics.Get(StatisticsPeriod.Cyclic, cycle, null, null, null, sort, offset, limit, select.Fields[0], quote)); + else + { + return Ok(new SelectionResponse + { + Cols = select.Fields, + Rows = await Statistics.Get(StatisticsPeriod.Cyclic, cycle, null, null, null, sort, offset, limit, select.Fields, quote) + }); + } + } + } + } +} diff --git a/Tzkt.Api/Models/Statistics/Statistics.cs b/Tzkt.Api/Models/Statistics/Statistics.cs new file mode 100644 index 000000000..d05b9374d --- /dev/null +++ b/Tzkt.Api/Models/Statistics/Statistics.cs @@ -0,0 +1,79 @@ +using System; + +namespace Tzkt.Api.Models +{ + public class Statistics + { + /// + /// Cycle at the end of which the statistics has been calculated. This field is only present in cyclic statistics. + /// + public int? Cycle { get; set; } + + /// + /// Day at the end of which the statistics has been calculated. This field is only present in daily statistics. + /// + public DateTime? Date { get; set; } + + /// + /// Level of the block at which the statistics has been calculated + /// + public int Level { get; set; } + + /// + /// Timestamp of the block at which the statistics has been calculated (ISO 8601, e.g. `2020-02-20T02:40:57Z`) + /// + public DateTime Timestamp { get; set; } + + /// + /// Total supply - all existing tokens (including locked vested funds and frozen funds) plus not yet activated fundraiser tokens + /// + public long TotalSupply { get; set; } + + /// + /// Circulating supply - all active tokens which can affect supply and demand (can be spent/transferred) + /// + public long CirculatingSupply { get; set; } + + /// + /// Total amount of tokens initially created when starting the blockchain + /// + public long TotalBootstrapped { get; set; } + + /// + /// Total commitment amount (tokens to be activated by fundraisers) + /// + public long TotalCommitments { get; set; } + + /// + /// Total amount of tokens activated by fundraisers + /// + public long TotalActivated { get; set; } + + /// + /// Total amount of created/issued tokens + /// + public long TotalCreated { get; set; } + + /// + /// Total amount of burned tokens + /// + public long TotalBurned { get; set; } + + /// + /// Total amount of tokens locked on vested contracts + /// + public long TotalVested { get; set; } + + /// + /// Total amount of frozen tokens (frozen security deposits, frozen rewards and frozen fees) + /// + public long TotalFrozen { get; set; } + + #region injecting + /// + /// Injected historical quote at the time of the block at which the statistics has been calculated + /// + public QuoteShort Quote { get; set; } + #endregion + } +} diff --git a/Tzkt.Api/Parameters/Binders/TimestampBinder.cs b/Tzkt.Api/Parameters/Binders/TimestampBinder.cs new file mode 100644 index 000000000..cbceb6754 --- /dev/null +++ b/Tzkt.Api/Parameters/Binders/TimestampBinder.cs @@ -0,0 +1,71 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Tzkt.Api.Services.Cache; + +namespace Tzkt.Api +{ + public class TimestampBinder : IModelBinder + { + readonly TimeCache Time; + + public TimestampBinder(TimeCache time) + { + Time = time; + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var model = bindingContext.ModelName; + var hasValue = false; + + if (!bindingContext.TryGetDateTime($"{model}", ref hasValue, out var value)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTime($"{model}.eq", ref hasValue, out var eq)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTime($"{model}.ne", ref hasValue, out var ne)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTime($"{model}.gt", ref hasValue, out var gt)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTime($"{model}.ge", ref hasValue, out var ge)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTime($"{model}.lt", ref hasValue, out var lt)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTime($"{model}.le", ref hasValue, out var le)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTimeList($"{model}.in", ref hasValue, out var @in)) + return Task.CompletedTask; + + if (!bindingContext.TryGetDateTimeList($"{model}.ni", ref hasValue, out var ni)) + return Task.CompletedTask; + + if (!hasValue) + { + bindingContext.Result = ModelBindingResult.Success(null); + return Task.CompletedTask; + } + + bindingContext.Result = ModelBindingResult.Success(new TimestampParameter + { + Eq = (value ?? eq) == null ? null : (int?)Time.FindLevel((DateTime)(value ?? eq), SearchMode.Exact), + Ne = ne == null ? null : (int?)Time.FindLevel((DateTime)ne, SearchMode.Exact), + Gt = gt == null ? null : (int?)Time.FindLevel((DateTime)gt, SearchMode.ExactOrLower), + Ge = ge == null ? null : (int?)Time.FindLevel((DateTime)ge, SearchMode.ExactOrHigher), + Lt = lt == null ? null : (int?)Time.FindLevel((DateTime)lt, SearchMode.ExactOrHigher), + Le = le == null ? null : (int?)Time.FindLevel((DateTime)le, SearchMode.ExactOrLower), + In = @in?.Select(x => Time.FindLevel(x, SearchMode.Exact)).ToList(), + Ni = ni?.Select(x => Time.FindLevel(x, SearchMode.Exact)).ToList(), + }); + + return Task.CompletedTask; + } + } +} diff --git a/Tzkt.Api/Parameters/TimestampParameter.cs b/Tzkt.Api/Parameters/TimestampParameter.cs new file mode 100644 index 000000000..ef00f27c0 --- /dev/null +++ b/Tzkt.Api/Parameters/TimestampParameter.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NJsonSchema.Annotations; +using Tzkt.Api.Services.Cache; + +namespace Tzkt.Api +{ + [ModelBinder(BinderType = typeof(TimestampBinder))] + public class TimestampParameter + { + /// + /// **Equal** filter mode (optional, i.e. `param.eq=123` is the same as `param=123`). \ + /// Specify a datetime value to get items where the specified field is equal to the specified value. + /// + /// Example: `?timestamp=2020-02-20T02:40:57Z`. + /// + [JsonSchemaType(typeof(DateTime?))] + public int? Eq { get; set; } + + /// + /// **Not equal** filter mode. \ + /// Specify a datetime value to get items where the specified field is not equal to the specified value. + /// + /// Example: `?timestamp.ne=2020-02-20T02:40:57Z`. + /// + [JsonSchemaType(typeof(DateTime?))] + public int? Ne { get; set; } + + /// + /// **Greater than** filter mode. \ + /// Specify a datetime value to get items where the specified field is greater than the specified value. + /// + /// Example: `?timestamp.gt=2020-02-20T02:40:57Z`. + /// + [JsonSchemaType(typeof(DateTime?))] + public int? Gt { get; set; } + + /// + /// **Greater or equal** filter mode. \ + /// Specify a datetime value to get items where the specified field is greater than equal to the specified value. + /// + /// Example: `?timestamp.ge=2020-02-20T02:40:57Z`. + /// + [JsonSchemaType(typeof(DateTime?))] + public int? Ge { get; set; } + + /// + /// **Less than** filter mode. \ + /// Specify a datetime value to get items where the specified field is less than the specified value. + /// + /// Example: `?timestamp.lt=2020-02-20T02:40:57Z`. + /// + [JsonSchemaType(typeof(DateTime?))] + public int? Lt { get; set; } + + /// + /// **Less or equal** filter mode. \ + /// Specify a datetime value to get items where the specified field is less than or equal to the specified value. + /// + /// Example: `?timestamp.le=2020-02-20T02:40:57Z`. + /// + [JsonSchemaType(typeof(DateTime?))] + public int? Le { get; set; } + + /// + /// **In list** (any of) filter mode. \ + /// Specify a comma-separated list of datetimes to get items where the specified field is equal to one of the specified values. + /// + /// Example: `?timestamp.in=2020-02-20,2020-02-21`. + /// + [JsonSchemaType(typeof(List))] + public List In { get; set; } + + /// + /// **Not in list** (none of) filter mode. \ + /// Specify a comma-separated list of datetimes to get items where the specified field is not equal to all the specified values. + /// + /// Example: `?timestamp.ni=2020-02-20,2020-02-21`. + /// + [JsonSchemaType(typeof(List))] + public List Ni { get; set; } + + #region static + public static Int32Parameter FromDateTimeParameter(DateTimeParameter timestamp, TimeCache time) + { + if (timestamp == null) return null; + + var res = new Int32Parameter(); + + if (timestamp.Eq != null) + res.Eq = time.FindLevel((DateTime)timestamp.Eq, SearchMode.Exact); + + if (timestamp.Ne != null) + res.Ne = time.FindLevel((DateTime)timestamp.Ne, SearchMode.Exact); + + if (timestamp.Gt != null) + res.Gt = time.FindLevel((DateTime)timestamp.Gt, SearchMode.ExactOrLower); + + if (timestamp.Ge != null) + res.Ge = time.FindLevel((DateTime)timestamp.Ge, SearchMode.ExactOrLower); + + return res; + } + #endregion + } +} diff --git a/Tzkt.Api/Repositories/BakingRightsRepository.cs b/Tzkt.Api/Repositories/BakingRightsRepository.cs index 486ea3cbd..aad5be40d 100644 --- a/Tzkt.Api/Repositories/BakingRightsRepository.cs +++ b/Tzkt.Api/Repositories/BakingRightsRepository.cs @@ -277,11 +277,11 @@ public async Task> GetSchedule(string address, DateT var fromLevel = from > state.Timestamp ? state.Level + (int)(from - state.Timestamp).TotalSeconds / proto.TimeBetweenBlocks - : Time.FindLevel(from, Nearest.Higher); + : Time.FindLevel(from, SearchMode.ExactOrHigher); var toLevel = to > state.Timestamp ? state.Level + (int)(to - state.Timestamp).TotalSeconds / proto.TimeBetweenBlocks - : Time.FindLevel(to, Nearest.Lower); + : Time.FindLevel(to, SearchMode.ExactOrLower); if (!(rawAccount is RawDelegate) || fromLevel == -1 || toLevel == -1) return Enumerable.Empty(); diff --git a/Tzkt.Api/Repositories/StatisticsRepository.cs b/Tzkt.Api/Repositories/StatisticsRepository.cs new file mode 100644 index 000000000..c82454631 --- /dev/null +++ b/Tzkt.Api/Repositories/StatisticsRepository.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Dapper; + +using Tzkt.Api.Models; +using Tzkt.Api.Services.Cache; + +namespace Tzkt.Api.Repositories +{ + public class StatisticsRepository : DbConnection + { + readonly TimeCache Time; + readonly QuotesCache Quotes; + + public StatisticsRepository(TimeCache time, QuotesCache quotes, IConfiguration config) : base(config) + { + Time = time; + Quotes = quotes; + } + + public async Task> Get( + StatisticsPeriod period, + Int32Parameter cycle, + Int32Parameter level, + TimestampParameter timestamp, + DateTimeParameter date, + SortParameter sort, + OffsetParameter offset, + int limit, + Symbols quote) + { + var sql = new SqlBuilder(@"SELECT * FROM ""Statistics"""); + + if (period == StatisticsPeriod.Cyclic) + sql.Filter(@"""Cycle"" IS NOT NULL"); + else if (period == StatisticsPeriod.Daily) + sql.Filter(@"""Date"" IS NOT NULL"); + + sql.Filter("Cycle", cycle) + .Filter("Level", level) + .Filter("Level", timestamp) + .Filter("Date", date) + .Take(sort, offset, limit, x => x switch + { + "level" => ("Level", "Level"), + "cycle" => ("Cycle", "Cycle"), + "date" => ("Date", "Date"), + _ => ("Id", "Id") + }); + + using var db = GetConnection(); + var rows = await db.QueryAsync(sql.Query, sql.Params); + + return rows.Select(row => new Statistics + { + Cycle = row.Cycle, + Date = row.Date, + Level = row.Level, + Timestamp = Time[row.Level], + TotalBootstrapped = row.TotalBootstrapped, + TotalCommitments = row.TotalCommitments, + TotalCreated = row.TotalCreated, + TotalBurned = row.TotalBurned, + TotalActivated = row.TotalActivated, + TotalVested = row.TotalVested, + TotalFrozen = row.TotalFrozen, + TotalSupply = row.TotalBootstrapped + row.TotalCommitments + row.TotalCreated - row.TotalBurned, + CirculatingSupply = row.TotalBootstrapped + row.TotalActivated + row.TotalCreated - row.TotalBurned - row.TotalVested - row.TotalFrozen, + Quote = Quotes.Get(quote, row.Level), + }); + } + + public async Task Get( + StatisticsPeriod period, + Int32Parameter cycle, + Int32Parameter level, + TimestampParameter timestamp, + DateTimeParameter date, + SortParameter sort, + OffsetParameter offset, + int limit, + string[] fields, + Symbols quote) + { + var columns = new HashSet(fields.Length + 8); + foreach (var field in fields) + { + switch (field) + { + case "cycle": columns.Add(@"""Cycle"""); break; + case "date": columns.Add(@"""Date"""); break; + case "level": columns.Add(@"""Level"""); break; + case "timestamp": columns.Add(@"""Level"""); break; + case "totalBootstrapped": columns.Add(@"""TotalBootstrapped"""); break; + case "totalCommitments": columns.Add(@"""TotalCommitments"""); break; + case "totalCreated": columns.Add(@"""TotalCreated"""); break; + case "totalBurned": columns.Add(@"""TotalBurned"""); break; + case "totalActivated": columns.Add(@"""TotalActivated"""); break; + case "totalVested": columns.Add(@"""TotalVested"""); break; + case "totalFrozen": columns.Add(@"""TotalFrozen"""); break; + case "totalSupply": + columns.Add(@"""TotalBootstrapped"""); + columns.Add(@"""TotalCommitments"""); + columns.Add(@"""TotalCreated"""); + columns.Add(@"""TotalBurned"""); + break; + case "circulatingSupply": + columns.Add(@"""TotalBootstrapped"""); + columns.Add(@"""TotalActivated"""); + columns.Add(@"""TotalCreated"""); + columns.Add(@"""TotalBurned"""); + columns.Add(@"""TotalVested"""); + columns.Add(@"""TotalFrozen"""); + break; + case "quote": columns.Add(@"""Level"""); break; + } + } + + if (columns.Count == 0) + return Array.Empty(); + + var sql = new SqlBuilder($@"SELECT {string.Join(',', columns)} FROM ""Statistics"""); + + if (period == StatisticsPeriod.Cyclic) + sql.Filter(@"""Cycle"" IS NOT NULL"); + else if (period == StatisticsPeriod.Daily) + sql.Filter(@"""Date"" IS NOT NULL"); + + sql.Filter("Cycle", cycle) + .Filter("Level", level) + .Filter("Level", timestamp) + .Filter("Date", date) + .Take(sort, offset, limit, x => x switch + { + "level" => ("Level", "Level"), + "cycle" => ("Cycle", "Cycle"), + "date" => ("Date", "Date"), + _ => ("Id", "Id") + }); + + using var db = GetConnection(); + var rows = await db.QueryAsync(sql.Query, sql.Params); + + var result = new object[rows.Count()][]; + for (int i = 0; i < result.Length; i++) + result[i] = new object[fields.Length]; + + for (int i = 0, j = 0; i < fields.Length; j = 0, i++) + { + switch (fields[i]) + { + case "cycle": + foreach (var row in rows) + result[j++][i] = row.Cycle; + break; + case "date": + foreach (var row in rows) + result[j++][i] = row.Date; + break; + case "level": + foreach (var row in rows) + result[j++][i] = row.Level; + break; + case "timestamp": + foreach (var row in rows) + result[j++][i] = Time[row.Level]; + break; + case "totalBootstrapped": + foreach (var row in rows) + result[j++][i] = row.TotalBootstrapped; + break; + case "totalCommitments": + foreach (var row in rows) + result[j++][i] = row.TotalCommitments; + break; + case "totalCreated": + foreach (var row in rows) + result[j++][i] = row.TotalCreated; + break; + case "totalBurned": + foreach (var row in rows) + result[j++][i] = row.TotalBurned; + break; + case "totalActivated": + foreach (var row in rows) + result[j++][i] = row.TotalActivated; + break; + case "totalVested": + foreach (var row in rows) + result[j++][i] = row.TotalVested; + break; + case "totalFrozen": + foreach (var row in rows) + result[j++][i] = row.TotalFrozen; + break; + case "totalSupply": + foreach (var row in rows) + result[j++][i] = row.TotalBootstrapped + row.TotalCommitments + row.TotalCreated - row.TotalBurned; + break; + case "circulatingSupply": + foreach (var row in rows) + result[j++][i] = row.TotalBootstrapped + row.TotalActivated + row.TotalCreated - row.TotalBurned - row.TotalVested - row.TotalFrozen; + break; + case "quote": + foreach (var row in rows) + result[j++][i] = Quotes.Get(quote, row.Level); + break; + } + } + + return result; + } + + public async Task Get( + StatisticsPeriod period, + Int32Parameter cycle, + Int32Parameter level, + TimestampParameter timestamp, + DateTimeParameter date, + SortParameter sort, + OffsetParameter offset, + int limit, + string field, + Symbols quote) + { + var columns = new HashSet(8); + switch (field) + { + case "cycle": columns.Add(@"""Cycle"""); break; + case "date": columns.Add(@"""Date"""); break; + case "level": columns.Add(@"""Level"""); break; + case "timestamp": columns.Add(@"""Level"""); break; + case "totalBootstrapped": columns.Add(@"""TotalBootstrapped"""); break; + case "totalCommitments": columns.Add(@"""TotalCommitments"""); break; + case "totalCreated": columns.Add(@"""TotalCreated"""); break; + case "totalBurned": columns.Add(@"""TotalBurned"""); break; + case "totalActivated": columns.Add(@"""TotalActivated"""); break; + case "totalVested": columns.Add(@"""TotalVested"""); break; + case "totalFrozen": columns.Add(@"""TotalFrozen"""); break; + case "totalSupply": + columns.Add(@"""TotalBootstrapped"""); + columns.Add(@"""TotalCommitments"""); + columns.Add(@"""TotalCreated"""); + columns.Add(@"""TotalBurned"""); + break; + case "circulatingSupply": + columns.Add(@"""TotalBootstrapped"""); + columns.Add(@"""TotalActivated"""); + columns.Add(@"""TotalCreated"""); + columns.Add(@"""TotalBurned"""); + columns.Add(@"""TotalVested"""); + columns.Add(@"""TotalFrozen"""); + break; + case "quote": columns.Add(@"""Level"""); break; + } + + if (columns.Count == 0) + return Array.Empty(); + + var sql = new SqlBuilder($@"SELECT {string.Join(',', columns)} FROM ""Statistics"""); + + if (period == StatisticsPeriod.Cyclic) + sql.Filter(@"""Cycle"" IS NOT NULL"); + else if (period == StatisticsPeriod.Daily) + sql.Filter(@"""Date"" IS NOT NULL"); + + sql.Filter("Cycle", cycle) + .Filter("Level", level) + .Filter("Level", timestamp) + .Filter("Date", date) + .Take(sort, offset, limit, x => x switch + { + "level" => ("Level", "Level"), + "cycle" => ("Cycle", "Cycle"), + "date" => ("Date", "Date"), + _ => ("Id", "Id") + }); + + using var db = GetConnection(); + var rows = await db.QueryAsync(sql.Query, sql.Params); + + var result = new object[rows.Count()]; + var j = 0; + + switch (field) + { + case "cycle": + foreach (var row in rows) + result[j++] = row.Cycle; + break; + case "date": + foreach (var row in rows) + result[j++] = row.Date; + break; + case "level": + foreach (var row in rows) + result[j++] = row.Level; + break; + case "timestamp": + foreach (var row in rows) + result[j++] = Time[row.Level]; + break; + case "totalBootstrapped": + foreach (var row in rows) + result[j++] = row.TotalBootstrapped; + break; + case "totalCommitments": + foreach (var row in rows) + result[j++] = row.TotalCommitments; + break; + case "totalCreated": + foreach (var row in rows) + result[j++] = row.TotalCreated; + break; + case "totalBurned": + foreach (var row in rows) + result[j++] = row.TotalBurned; + break; + case "totalActivated": + foreach (var row in rows) + result[j++] = row.TotalActivated; + break; + case "totalVested": + foreach (var row in rows) + result[j++] = row.TotalVested; + break; + case "totalFrozen": + foreach (var row in rows) + result[j++] = row.TotalFrozen; + break; + case "totalSupply": + foreach (var row in rows) + result[j++] = row.TotalBootstrapped + row.TotalCommitments + row.TotalCreated - row.TotalBurned; + break; + case "circulatingSupply": + foreach (var row in rows) + result[j++] = row.TotalBootstrapped + row.TotalActivated + row.TotalCreated - row.TotalBurned - row.TotalVested - row.TotalFrozen; + break; + case "quote": + foreach (var row in rows) + result[j++] = Quotes.Get(quote, row.Level); + break; + } + + return result; + } + } + + public enum StatisticsPeriod + { + None, + Daily, + Cyclic + } +} diff --git a/Tzkt.Api/Services/Cache/Time/TimeCache.cs b/Tzkt.Api/Services/Cache/Time/TimeCache.cs index 610bd8cf2..6261ee97c 100644 --- a/Tzkt.Api/Services/Cache/Time/TimeCache.cs +++ b/Tzkt.Api/Services/Cache/Time/TimeCache.cs @@ -54,16 +54,16 @@ public TimeCache(IConfiguration config, ILogger logger) : base(config } } - public int FindLevel(DateTime datetime, Nearest mode) + public int FindLevel(DateTime datetime, SearchMode mode) { if (Times.Count == 0) return -1; if (datetime > Times[^1]) - return mode == Nearest.Lower ? Times.Count - 1 : -1; + return mode == SearchMode.ExactOrLower ? Times.Count - 1 : -1; if (datetime < Times[0]) - return mode == Nearest.Higher ? 0 : -1; + return mode == SearchMode.ExactOrHigher ? 0 : -1; #region binary search var from = 0; @@ -88,7 +88,8 @@ public int FindLevel(DateTime datetime, Nearest mode) } } - return mode == Nearest.Higher ? from : to; + return mode == SearchMode.Exact ? -1 : + mode == SearchMode.ExactOrHigher ? from : to; #endregion } @@ -125,10 +126,11 @@ public async Task UpdateAsync() } } - public enum Nearest + public enum SearchMode { - Lower, - Higher + Exact, + ExactOrLower, + ExactOrHigher } public static class TimeCacheExt diff --git a/Tzkt.Api/Startup.cs b/Tzkt.Api/Startup.cs index cd4e0e5ec..35d90a132 100644 --- a/Tzkt.Api/Startup.cs +++ b/Tzkt.Api/Startup.cs @@ -55,6 +55,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSynchronization(); diff --git a/Tzkt.Api/Utils/SqlBuilder.cs b/Tzkt.Api/Utils/SqlBuilder.cs index 080d2faba..a3bfe85a2 100644 --- a/Tzkt.Api/Utils/SqlBuilder.cs +++ b/Tzkt.Api/Utils/SqlBuilder.cs @@ -798,6 +798,80 @@ public SqlBuilder FilterA(string column, DateTimeParameter value, Func map = null) + { + if (value == null) return this; + + if (value.Eq != null) + AppendFilter($@"""{column}"" = {value.Eq}"); + + if (value.Ne != null) + AppendFilter($@"""{column}"" != {value.Ne}"); + + if (value.Gt != null) + AppendFilter($@"""{column}"" > {value.Gt}"); + + if (value.Ge != null) + AppendFilter($@"""{column}"" >= {value.Ge}"); + + if (value.Lt != null) + AppendFilter($@"""{column}"" < {value.Lt}"); + + if (value.Le != null) + AppendFilter($@"""{column}"" <= {value.Le}"); + + if (value.In != null) + { + AppendFilter($@"""{column}"" = ANY (@{column}In)"); + Params.Add($"{column}In", value.In); + } + + if (value.Ni != null) + { + AppendFilter($@"NOT (""{column}"" = ANY (@{column}Ni))"); + Params.Add($"{column}Ni", value.Ni); + } + + return this; + } + + public SqlBuilder FilterA(string column, TimestampParameter value, Func map = null) + { + if (value == null) return this; + + if (value.Eq != null) + AppendFilter($@"{column} = {value.Eq}"); + + if (value.Ne != null) + AppendFilter($@"{column} != {value.Ne}"); + + if (value.Gt != null) + AppendFilter($@"{column} > {value.Gt}"); + + if (value.Ge != null) + AppendFilter($@"{column} >= {value.Ge}"); + + if (value.Lt != null) + AppendFilter($@"{column} < {value.Lt}"); + + if (value.Le != null) + AppendFilter($@"{column} <= {value.Le}"); + + if (value.In != null) + { + AppendFilter($@"{column} = ANY (@p{Counter})"); + Params.Add($"p{Counter++}", value.In); + } + + if (value.Ni != null) + { + AppendFilter($@"NOT ({column} = ANY (@p{Counter}))"); + Params.Add($"p{Counter++}", value.Ni); + } + + return this; + } + public SqlBuilder Take(SortParameter sort, OffsetParameter offset, int limit, Func map) { var sortAsc = true;