diff --git a/.gitignore b/.gitignore index f0477ff..e05ee9c 100644 --- a/.gitignore +++ b/.gitignore @@ -447,3 +447,7 @@ fabric.properties .idea/caches/build_file_checksums.ser # End of https://www.toptal.com/developers/gitignore/api/dotnetcore,rider,csharp + +# Custom .gitignore +WebFrontend/data.db + diff --git a/CryptoStatsSource/CoingeckoSource.cs b/CryptoStatsSource/CoingeckoSource.cs index 99696cd..c834163 100644 --- a/CryptoStatsSource/CoingeckoSource.cs +++ b/CryptoStatsSource/CoingeckoSource.cs @@ -10,18 +10,28 @@ namespace CryptoStatsSource { public class CoingeckoSource : ICryptoStatsSource { + // coingecko URL private const string BaseUrl = "https://api.coingecko.com/api/"; + + // API version private const string ApiVersion = "v3"; + // initialise a HTTP client private readonly TinyRestClient _client = new(new HttpClient(), $"{BaseUrl}{ApiVersion}/"); public CoingeckoSource() { + // set JSON attribute name formatting to snake case _client.Settings.Formatters.OfType().First().UseSnakeCase(); } + // make a call to the coins/markets API endpoint public async Task> GetMarketEntries(string currency, params string[] ids) => await _client .GetRequest("coins/markets").AddQueryParameter("vs_currency", currency) .AddQueryParameter("ids", String.Join(",", ids)).ExecuteAsync>(); + + // make a call to the coins/list API endpoint + public async Task> GetAvailableCryptocurrencies() => await _client + .GetRequest("coins/list").ExecuteAsync>(); } } \ No newline at end of file diff --git a/CryptoStatsSource/CryptoNameResolver.cs b/CryptoStatsSource/CryptoNameResolver.cs new file mode 100644 index 0000000..1e1cd40 --- /dev/null +++ b/CryptoStatsSource/CryptoNameResolver.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CryptoStatsSource.model; + +namespace CryptoStatsSource +{ + public interface ICryptocurrencyResolver + { + /// + /// Resolves a cryptocurrency based on the given cryptocurrency symbol. Returns null value when the symbol does + /// not map to any cryptocurrency. + /// + /// Symbol based on which the cryptocurrency should be resolved + /// A task containing resolved cryptocurrency when finished + public Task Resolve(string symbol); + + /// + /// Refreshes the database of cryptocurrencies mapped to their symbols + /// + /// A task that is finished when all cryptocurrencies are fetched and mapped to their symbols + public Task Refresh(); + } + + public class CryptocurrencyResolverImpl : ICryptocurrencyResolver + { + // used for retrieving cryptocurrency info + private ICryptoStatsSource _cryptoStatsSource; + + // a dictionary mapping symbols to cryptocurrencies + private Dictionary _nameToCryptocurrencyDictionary; + + /// CryptoStatsSource interface to be used + public CryptocurrencyResolverImpl(ICryptoStatsSource cryptoStatsSource) + { + _cryptoStatsSource = cryptoStatsSource; + } + + public async Task Refresh() + { + // initialize the dictionary + _nameToCryptocurrencyDictionary = new(); + + // fetch all cryptocurrencies and add them to the dictionary using the symbol as a key + (await _cryptoStatsSource.GetAvailableCryptocurrencies()) + // workaround till Coingecko removes binance-peg entries + .Where(c => !c.Id.Contains("binance-peg")).ToList() + .ForEach(c => + _nameToCryptocurrencyDictionary.TryAdd(c.Symbol, c)); + } + + public async Task Resolve(string symbol) + { + // refresh the dictionary if the symbol was not found in it + if (_nameToCryptocurrencyDictionary?.GetValueOrDefault(symbol) == null) await Refresh(); + + return _nameToCryptocurrencyDictionary.GetValueOrDefault(symbol, null); + } + } +} \ No newline at end of file diff --git a/CryptoStatsSource/CryptoStatsSource.cs b/CryptoStatsSource/CryptoStatsSource.cs index cdbfb12..53d97a7 100644 --- a/CryptoStatsSource/CryptoStatsSource.cs +++ b/CryptoStatsSource/CryptoStatsSource.cs @@ -6,6 +6,18 @@ namespace CryptoStatsSource { public interface ICryptoStatsSource { + /// + /// Gets all market entries matching the given IDs, price values are relative to the currency specified. + /// + /// Code ("usd", "eur", "czk",...) of the currency the market entry prices should be relative to + /// IDs of the market entries to be fetched + /// List of loaded market entries public Task> GetMarketEntries(string currency, params string[] ids); + + /// + /// Gets a list of all available cryptocurrencies + /// + /// List of all available cryptocurrencies + public Task> GetAvailableCryptocurrencies(); } } \ No newline at end of file diff --git a/CryptoStatsSource/CryptoStatsSource.csproj b/CryptoStatsSource/CryptoStatsSource.csproj index bc7687e..7241111 100644 --- a/CryptoStatsSource/CryptoStatsSource.csproj +++ b/CryptoStatsSource/CryptoStatsSource.csproj @@ -4,6 +4,7 @@ net5.0 CryptoStatsSource CryptoStatsSource + false diff --git a/CryptoStatsSource/Model/MarketEntry.cs b/CryptoStatsSource/Model/MarketEntry.cs deleted file mode 100644 index 1f9bda8..0000000 --- a/CryptoStatsSource/Model/MarketEntry.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace CryptoStatsSource.model -{ - public record MarketEntry(string Id, string Symbol, string Name, decimal CurrentPrice, long MarketCap, - [JsonProperty("price_change_24h")] float PriceChange24H, [JsonProperty("price_change_percentage_24h")] float PriceChangePercentage24H); - - public record PriceEntry(string Id, string Symbol, string Name, decimal CurrentPrice, - float PriceChangePercentage24H); -} \ No newline at end of file diff --git a/CryptoStatsSource/Model/SourceModel.cs b/CryptoStatsSource/Model/SourceModel.cs new file mode 100644 index 0000000..270cb83 --- /dev/null +++ b/CryptoStatsSource/Model/SourceModel.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json; + +namespace CryptoStatsSource.model +{ + /// + /// A record representing a market entry (market evaluation of a given cryptocurrency) + /// + /// + /// Id of the market entry + /// + /// + /// Symbol of the market entry + /// + /// + /// Price of the entry evaluated at the time of creation + /// + /// + /// Market cap of the entry + /// + /// + /// Price change of the entry in the last 24 hours + /// > + /// + /// Relative price change of the entry in the last 24 hours + /// + public record MarketEntry(string Id, string Symbol, string Name, decimal CurrentPrice, long MarketCap, + [JsonProperty("price_change_24h", NullValueHandling = NullValueHandling.Ignore)] + float? PriceChange24H = 0f, + [JsonProperty("price_change_percentage_24h", NullValueHandling = NullValueHandling.Ignore)] + float? PriceChangePercentage24H = 0f + ); + + /// + /// A record containing a basic information about a cryptocurrency + /// + /// + /// ID of the cryptocurrency + /// + /// + /// Symbol of the cryptocurrency + /// + /// + /// Name of the cryptocurrency + /// + public record Cryptocurrency(string Id, string Symbol, string Name); +} \ No newline at end of file diff --git a/Database/SqlKataDatabase.cs b/Database/SqlKataDatabase.cs index a905e97..3a0a258 100644 --- a/Database/SqlKataDatabase.cs +++ b/Database/SqlKataDatabase.cs @@ -26,8 +26,6 @@ public SqlKataDatabase(IDbConnection dbConnection, Compiler compiler) public void Dispose() { - // TODO check whether dispose is called - Console.WriteLine("Disposing SqlKataDatabase"); _dbConnection.Close(); } } diff --git a/Database/SqlSchema.cs b/Database/SqlSchema.cs index 9a97f4b..821c86e 100644 --- a/Database/SqlSchema.cs +++ b/Database/SqlSchema.cs @@ -4,33 +4,55 @@ namespace Database { public class SqlSchema { + public const string TableIdPrimaryKey = "id"; + + public const string TablePortfolios = "portfolios"; + public const string PortfoliosId = TableIdPrimaryKey; + public const string PortfoliosName = "name"; + public const string PortfoliosDescription = "description"; + public const string PortfoliosCurrencyCode = "currency_code"; + + public const string TablePortfolioEntries = "portfolio_entries"; + public const string PortfolioEntriesId = TableIdPrimaryKey; + public const string PortfolioEntriesSymbol = "symbol"; + public const string PortfolioEntriesPortfolioId = "portfolio_id"; + + public const string TableMarketOrders = "market_orders"; + public const string MarketOrdersId = TableIdPrimaryKey; + public const string MarketOrdersFilledPrice = "filled_price"; + public const string MarketOrdersFee = "fee"; + public const string MarketOrdersSize = "size"; + public const string MarketOrdersDate = "date"; + public const string MarketOrdersBuy = "buy"; + public const string MarketOrdersPortfolioEntryId = "portfolio_entry_id"; + public static void Init(SqlKataDatabase db) { - db.Get().Statement(@" + db.Get().Statement($@" - CREATE TABLE IF NOT EXISTS portfolios ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - description TEXT NOT NULL + CREATE TABLE IF NOT EXISTS {TablePortfolios} ( + {PortfoliosId} INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + {PortfoliosName} TEXT NOT NULL, + {PortfoliosDescription} TEXT NOT NULL, + {PortfoliosCurrencyCode} INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS portfolio_entries ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - symbol TEXT NOT NULL, - portfolio_id INTEGER NOT NULL, - FOREIGN KEY(portfolio_id) REFERENCES portfolios(id) + CREATE TABLE IF NOT EXISTS {TablePortfolioEntries} ( + {PortfolioEntriesId} INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + {PortfolioEntriesSymbol} TEXT NOT NULL, + {PortfolioEntriesPortfolioId} INTEGER NOT NULL, + FOREIGN KEY({PortfolioEntriesPortfolioId}) REFERENCES {TablePortfolios}(id) ); - CREATE TABLE IF NOT EXISTS market_orders ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - currency TEXT NOT NULL, - filled_price INTEGER NOT NULL, - fee INTEGER NOT NULL, - size INTEGER NOT NULL, - date INTEGER NOT NULL, - buy INTEGER NOT NULL, - portfolio_entry_id INTEGER NOT NULL, - FOREIGN KEY(portfolio_entry_id) REFERENCES portfolio_entries(id) + CREATE TABLE IF NOT EXISTS {TableMarketOrders} ( + {MarketOrdersId} INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + {MarketOrdersFilledPrice} INTEGER NOT NULL, + {MarketOrdersFee} INTEGER NOT NULL, + {MarketOrdersSize} INTEGER NOT NULL, + {MarketOrdersDate} INTEGER NOT NULL, + {MarketOrdersBuy} INTEGER NOT NULL, + {MarketOrdersPortfolioEntryId} INTEGER NOT NULL, + FOREIGN KEY({MarketOrdersPortfolioEntryId}) REFERENCES {TablePortfolioEntries}(id) ); "); diff --git a/Model/Model.cs b/Model/Model.cs index 5ff32b4..93d7bb5 100644 --- a/Model/Model.cs +++ b/Model/Model.cs @@ -1,18 +1,81 @@ using System; -public record PortfolioEntry(string Symbol, int PortfolioId = -1, int Id = -1); namespace Model { - public record MarketOrder(Currency Currency, decimal FilledPrice, decimal Fee, decimal Size, - DateTime Date, bool Buy, int Id = -1, int PortfolioEntryId = -1); + /// + /// A record that represents a cryptocurrency portfolio + /// + /// + /// Name of the portfolio + /// + /// + /// Currency in which trades are made in the portfolio + /// + /// + /// ID of the portfolio (defaults to -1) + /// + public record Portfolio(string Name, string Description, Currency Currency, int Id = -1); - public record Portfolio(string Name, string Description, int Id = -1); + /// + /// A record that represents an entry of a portfolio + /// + /// + /// A symbol of the cryptocurrency to be traded in within the entry + /// + /// + /// ID of the portfolio this entry belongs to + /// + /// + /// ID of the portfolio (defaults to -1) + /// + public record PortfolioEntry(string Symbol, int PortfolioId = -1, int Id = -1); - public enum Currency + /// + /// A record that represents a market order + /// + /// + /// Price of the asset the moment itwas traded + /// + /// + /// A fee for the trade made + /// + /// + /// Order size + /// + /// + /// Date when the trade was made + /// + /// + /// A flag indicating whether the trade was a buy or a sell + /// + /// + /// ID of the order + /// + /// + /// ID of the portfolio entry this trade belongs to + /// + public record MarketOrder(decimal FilledPrice, decimal Fee, decimal Size, + DateTime Date, bool Buy, int Id = -1, int PortfolioEntryId = -1) { - Czk, - Eur, - Usd + public virtual bool Equals(MarketOrder? other) + { + // override the Equals operator so Dates are compared in a more intuitive way + if (other == null) return false; + + return FilledPrice == other.FilledPrice && Fee == other.Fee && Size == other.Size && + Date.ToString() == other.Date.ToString() && Buy == other.Buy && Id == other.Id && + PortfolioEntryId == other.PortfolioEntryId; + } + } + + /// + /// An enumerable representing currencies + /// + public enum Currency : int + { + Czk = 203, + Eur = 978, + Usd = 849 } } \ No newline at end of file diff --git a/README.md b/README.md index 5749688..891b8d5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # Crypto Portfolio Tracker A tracker of your crypto portfolio, written in C# using .NET core. Made as KIV/NET semester project at Západočeská univerzita v Plzni. +![Application screenshot](doc/img/cpt-screenshots/portfolio-entry-detail.png) +## Features +- create and manage portfolios +- create and manage portfolio entries +- add transactions that are linked to portfolio entries +- see summaries of portfolios, portfolio entries and transactions + - based on current cryptocurrency market data fetched from [CoinGecko](https://www.coingecko.com/en/api) +## Electron +- install required tools from [Electron.NET](https://github.com/ElectronNET/Electron.NET) and then run: + +```electronize start /PublishSingleFile false /PublishReadyToRun false --no-self-contained``` + diff --git a/Repository/IRepository.cs b/Repository/IRepository.cs index 3eb1739..86b0142 100644 --- a/Repository/IRepository.cs +++ b/Repository/IRepository.cs @@ -1,26 +1,93 @@ +using System.Collections.Generic; using Model; namespace Repository { - // TODO comments + /// + /// An interface that represents a general repository for storing entities to a persistent storage + /// + /// Type of the entity to be used public interface IRepository { - public object ToRow(T entry); - + /// + /// Adds an entity to the repository + /// + /// Entity to be added + /// public int Add(T entry); + /// + /// Loads an entity specified by an ID from a repository + /// + /// ID of the entity to be loaded + /// Found entity or null public T Get(int id); + + /// + /// Loads all entities from a repository + /// + /// Collection of all entities present in a repository + public List GetAll(); + /// + /// Saves the updated version of an entity to the database overriding it's previous version + /// + /// Updated version of already an entity already existing in the repository + /// A flag indicating whether the update was successful public bool Update(T entry); + /// + /// Deletes the given entity from the repository + /// + /// An entity to be deleted from the repository + /// A flag indicating whether the entity was deleted successfully public bool Delete(T entry); } + /// + /// A repository interface used for storing market orders + /// public interface IMarketOrderRepository : IRepository { + /// + /// Gets all market orders of a portfolio entry + /// + /// ID of the entry whose orders should be loaded + /// A collection of all orders assigned to the portfolio entry + public List GetAllByPortfolioEntryId(int portfolioEntryId); + + /// + /// Deletes all market orders of the portfolio entry given by an ID + /// + /// ID of the entry whose orders should be deleted + /// Number of orders deleted + public int DeletePortfolioEntryOrders(int portfolioEntryId); } + /// + /// A repository interface used for storing portfolios + /// public interface IPortfolioRepository : IRepository { } -} \ No newline at end of file + + /// + /// A repository interface used for storing portfolio entries + /// + public interface IPortfolioEntryRepository : IRepository + { + /// + /// Gets all portfolio entries of a portfolio given by an ID + /// + /// ID of the portfolio whose entries should be loaded + /// A collection of all portfolio entries assigned to the portfolio + public List GetAllByPortfolioId(int portfolioId); + + /// + /// Deletes all portfolio entries of the portfolio given by an ID + /// + /// ID of the portfolio whose entries should be deleted + /// A flag indicating whether the entries have been succesfully deleted + public int DeletePortfolioEntries(int portfolioId); + } +} diff --git a/Repository/Repository.csproj b/Repository/Repository.csproj index dd5d13c..a56a9f5 100644 --- a/Repository/Repository.csproj +++ b/Repository/Repository.csproj @@ -4,6 +4,7 @@ net5.0 Repository Repository + false diff --git a/Repository/SqlKataMarketOrderRepository.cs b/Repository/SqlKataMarketOrderRepository.cs index 33a81b6..05cf5d0 100644 --- a/Repository/SqlKataMarketOrderRepository.cs +++ b/Repository/SqlKataMarketOrderRepository.cs @@ -1,38 +1,60 @@ using System; +using System.Collections.Generic; +using System.Linq; using Database; using Model; +using SqlKata.Execution; namespace Repository { - public class SqlKataMarketOrderRepository : SqlKataRepository + /// + /// Implements the IMarketOrderRepository by extending SqlKataRepository and implementing necessary methods + /// + public class SqlKataMarketOrderRepository : SqlKataRepository, IMarketOrderRepository { - public SqlKataMarketOrderRepository(SqlKataDatabase db) : base(db, "market_orders") + // decimal precision to be used when storing order price and size (enough to support satoshis - smallest unit of bitcoin) + private const int DecimalPrecision = 100000000; + + public SqlKataMarketOrderRepository(SqlKataDatabase db) : base(db, SqlSchema.TableMarketOrders) { } - protected override int _getEntryId(MarketOrder entry) => entry.Id; - - public override object ToRow(MarketOrder entry) => new - { - currency = entry.Currency.ToString(), - filled_price = (int) (entry.FilledPrice * 100), - fee = (int) (entry.Fee * 100), - size = (int) (entry.Size * 100), - date = entry.Date.ToBinary(), - buy = ((DateTimeOffset) entry.Date).ToUnixTimeSeconds(), - portfolio_entrY_id = entry.PortfolioEntryId, - }; + protected override int GetEntryId(MarketOrder entry) => entry.Id; - public override MarketOrder FromRow(dynamic d) + protected override object ToRow(MarketOrder entry) { - bool parsed = Enum.TryParse(d.currency, out Currency currency); - if (!parsed) + return new { - throw new SqlKataRepositoryException($"Failed to parse currency {d.currency}"); - } - - return new(currency, d.filled_price, d.fee, d.size, Utils.DateUtils.UnixTimeStampToDateTime(d.date), d.buy > 0, - 0, 0); + // make sure to multiply the price, fee and size with the decimal precision + filled_price = (long) (entry.FilledPrice * DecimalPrecision), + fee = (long) (entry.Fee * DecimalPrecision), + size = (long) (entry.Size * DecimalPrecision), + // date stored as unix timestamps + date = ((DateTimeOffset) entry.Date).ToUnixTimeSeconds(), + buy = entry.Buy ? 1 : 0, + portfolio_entry_id = entry.PortfolioEntryId, + }; } + + protected override MarketOrder FromRow(dynamic d) => + // make sure to divide the price, fee and size by the decimal precision + new( + Decimal.Divide(d.filled_price, DecimalPrecision), + Decimal.Divide(d.fee, DecimalPrecision), + Decimal.Divide(d.size, DecimalPrecision), + DateTimeOffset.FromUnixTimeSeconds((int) d.date).DateTime.ToLocalTime(), + d.buy > 0, + (int) d.id, + (int) d.portfolio_entry_id + ); + + public List GetAllByPortfolioEntryId(int portfolioEntryId) => + // implement the method using the WHERE statement + RowsToObjects(Db.Get().Query(TableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId) + .Get()); + + public int DeletePortfolioEntryOrders(int portfolioEntryId) => + // implement the method using the WHERE statement + Db.Get().Query(TableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId).Delete(); } } \ No newline at end of file diff --git a/Repository/SqlKataPortfolioEntryRepository.cs b/Repository/SqlKataPortfolioEntryRepository.cs index 1e361b4..9ba51b1 100644 --- a/Repository/SqlKataPortfolioEntryRepository.cs +++ b/Repository/SqlKataPortfolioEntryRepository.cs @@ -1,22 +1,33 @@ +using System.Collections.Generic; using Database; using Model; +using SqlKata.Execution; namespace Repository { - public class SqlKataPortfolioEntryRepository : SqlKataRepository + /// + /// Implements the IPortfolioEntryRepository by extending SqlKataRepository and implementing necessary methods + /// + public class SqlKataPortfolioEntryRepository : SqlKataRepository, IPortfolioEntryRepository { - public SqlKataPortfolioEntryRepository(SqlKataDatabase db) : base(db, "portfolio_entries") + public SqlKataPortfolioEntryRepository(SqlKataDatabase db) : base(db, SqlSchema.TablePortfolioEntries) { } - protected override int _getEntryId(PortfolioEntry entry) => entry.Id; + protected override int GetEntryId(PortfolioEntry entry) => entry.Id; - public override object ToRow(PortfolioEntry entry) => new + protected override object ToRow(PortfolioEntry entry) => new { symbol = entry.Symbol, portfolio_id = entry.PortfolioId }; - - public override PortfolioEntry FromRow(dynamic d) => new((string) d.symbol, (int) d.portfolio_id, (int) d.id); + + protected override PortfolioEntry FromRow(dynamic d) => new((string) d.symbol, (int) d.portfolio_id, (int) d.id); + + public List GetAllByPortfolioId(int portfolioId) => + RowsToObjects(Db.Get().Query(TableName).Where(SqlSchema.PortfolioEntriesPortfolioId, portfolioId).Get()); + + public int DeletePortfolioEntries(int portfolioId) => + Db.Get().Query(TableName).Where(SqlSchema.PortfolioEntriesPortfolioId, portfolioId).Delete(); } } \ No newline at end of file diff --git a/Repository/SqlKataPortfolioRepository.cs b/Repository/SqlKataPortfolioRepository.cs index 206c43e..c58e5e9 100644 --- a/Repository/SqlKataPortfolioRepository.cs +++ b/Repository/SqlKataPortfolioRepository.cs @@ -3,21 +3,26 @@ namespace Repository { - public class SqlKataPortfolioRepository : SqlKataRepository + + /// + /// Implements the IPortfolioRepository by extending SqlKataRepository and implementing necessary methods + /// + public class SqlKataPortfolioRepository : SqlKataRepository, IPortfolioRepository { - public SqlKataPortfolioRepository(SqlKataDatabase db) : base(db, "portfolios") + public SqlKataPortfolioRepository(SqlKataDatabase db) : base(db, SqlSchema.TablePortfolios) { } - protected override int _getEntryId(Portfolio entry) => entry.Id; + protected override int GetEntryId(Portfolio entry) => entry.Id; - public override object ToRow(Portfolio entry) => new + protected override object ToRow(Portfolio entry) => new { name = entry.Name, - description = entry.Description + description = entry.Description, + currency_code = (int) entry.Currency }; - public override Portfolio FromRow(dynamic d) => new((string) d.name, (string) d.description, (int) d.id); - + protected override Portfolio FromRow(dynamic d) => + new((string) d.name, (string) d.description, (Currency) d.currency_code, (int) d.id); } } \ No newline at end of file diff --git a/Repository/SqlKataRepository.cs b/Repository/SqlKataRepository.cs index 750d026..2f8283b 100644 --- a/Repository/SqlKataRepository.cs +++ b/Repository/SqlKataRepository.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using Database; @@ -9,67 +8,71 @@ namespace Repository // Implements IRepository using a SqlKataDatabase public abstract class SqlKataRepository : IRepository { + // hold a reference to the SqlKataDatabase instance protected readonly SqlKataDatabase Db; - private readonly string _tableName; + + // name of the table this repository utilizes + protected readonly string TableName; + /// Instance of the database object + /// Name of the table to be utilized public SqlKataRepository(SqlKataDatabase db, string tableName) { - this.Db = db; - this._tableName = tableName; + Db = db; + TableName = tableName; } - public abstract object ToRow(T entry); + /// + /// Converts a object of type T to a generic untyped object (to a table row) + /// + /// An object of type T to be converted + /// Generic object (table row) representing the given object of type T + protected abstract object ToRow(T entry); - public abstract T FromRow(dynamic d); + /// + /// Converts a generic object (table row) to an object of type T + /// + /// Object to be converted to an object of type T + /// Returns an object of type T converted from the generic object (table row) + protected abstract T FromRow(dynamic d); - protected abstract int _getEntryId(T entry); + /// + /// Returns an ID of the given object of type T + /// + /// Object whose ID should be returner + /// ID of the given object + protected abstract int GetEntryId(T entry); - public int Add(T entry) - { - var id = Db.Get().Query(_tableName).InsertGetId(ToRow(entry)); - return id; - } + /// + /// Returns the name of the primary key column + /// + protected string GetPrimaryKeyColumnName => SqlSchema.TableIdPrimaryKey; - public bool Update(T entry) - { - var updated = Db.Get().Query(_tableName).Where("id", _getEntryId(entry)).Update(ToRow(entry)); - return updated > 0; - } + // use the InsertGetId method of the SqlKata library in order to insert a new row and get it's ID + public int Add(T entry) => Db.Get().Query(TableName).InsertGetId(ToRow(entry)); - public bool Delete(T entry) - { - Db.Get().Query(_tableName).Where("id", _getEntryId(entry)).AsDelete(); - // TODO - return true; - } + // updates the given object of type T using the WHERE and UPDATE method calls, returns a boolean flag indicating + // whether at least one table row was updated + public bool Update(T entry) => + Db.Get().Query(TableName).Where(GetPrimaryKeyColumnName, GetEntryId(entry)).Update(ToRow(entry)) > 0; + // deletes the given object of type T and returns a boolean flag indicating whether at least one table row was deleted + public bool Delete(T entry) => Db.Get().Query(TableName).Where(GetPrimaryKeyColumnName, GetEntryId(entry)).Delete() > 0; + public T Get(int id) { - var result = Db.Get().Query(_tableName).Where("id", id).First(); - if (result == null) - { - return default; - } - - return FromRow(result); + // find a table rows based on the given ID + var result = Db.Get().Query(TableName).Where(GetPrimaryKeyColumnName, id).FirstOrDefault(); + + // convert the given table row a ta an object of type T + return result == null ? default : FromRow(result); } - public List All() - { - List items = new List(); - foreach (var row in Db.Get().Query(_tableName).Select().Get()) - { - items.Add(FromRow(row)); - } - - return items; - } - } + // select all rows from the database and converts them to objects of type T + public List GetAll() => RowsToObjects(Db.Get().Query(TableName).Select().Get()); - public class SqlKataRepositoryException : Exception - { - public SqlKataRepositoryException(string message) : base(message) - { - } + // converts a collection of table rows to a list of objects of type T + protected List RowsToObjects(IEnumerable rows) => rows.Select(row => (T) FromRow(row)).ToList(); + } } \ No newline at end of file diff --git a/Services/Services/MarketOrderService.cs b/Services/Services/MarketOrderService.cs new file mode 100644 index 0000000..4bf5ee8 --- /dev/null +++ b/Services/Services/MarketOrderService.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Model; +using Repository; + +namespace Services +{ + /// + /// A service that is responsible for managing market orders and storing them to a persistent repository. + /// + public interface IMarketOrderService + { + /// + /// Creates a new market order and adds it to a repository + /// + /// The agreed price per one piece of the traded asset + /// Fee for trade + /// Size of the trade + /// Date the trade was made + /// A flag indicating whether the trade is a buy + /// ID of the portfolio entry the trade belongs to + /// Created instance of the `MarketOrder` class + MarketOrder CreateMarketOrder(decimal filledPrice, decimal fee, decimal size, + DateTime date, bool buy, int portfolioEntryId); + + /// + /// Deletes the given order from the repository + /// + /// Order to be deleted from the repository + /// A flag indicating whether an order was deleted + bool DeleteMarketOrder(MarketOrder order); + + /// + /// Updates the given order in the repository. The order with the same ID in the repository is replaced with the one + /// passed. + /// + /// Updated order + /// A flag indicating whether an order was updated + bool UpdateMarketOrder(MarketOrder order); + + /// + /// Loads and returns a market order from a repository + /// + /// ID of the order to be loaded + /// Loaded order from the repository or `null` when no order with the given ID was found in the repository + MarketOrder GetMarketOrder(int id); + + /// + /// Gets all orders of a portfolio entry given by an ID. + /// + /// ID of the entry whose orders are to be found + /// List of all orders belonging to the order specified by the given ID + List GetPortfolioEntryOrders(int portfolioEntryId); + + /// + /// Deletes all orders belonging to the portfolio entry given by it's ID + /// + /// ID of the portfolio entry whose orders are to be deleted + /// Number of deleted orders + int DeletePortfolioEntryOrders(int portfolioEntryId); + } + + public class MarketOrderServiceImpl : IMarketOrderService + { + private readonly IMarketOrderRepository _marketOrderRepository; + + public MarketOrderServiceImpl(IMarketOrderRepository marketOrderRepository) + { + _marketOrderRepository = marketOrderRepository; + } + + public MarketOrder CreateMarketOrder(decimal filledPrice, decimal fee, decimal size, DateTime date, bool buy, + int portfolioEntryId) + { + // create a MarketOrder instance + var order = new MarketOrder(filledPrice, fee, size, date, buy, PortfolioEntryId: portfolioEntryId); + + // add it to the repository + var id = _marketOrderRepository.Add(order); + + // return the created instance with the ID generated by the repository + return order with {Id = id}; + } + + public bool DeleteMarketOrder(MarketOrder order) => _marketOrderRepository.Delete(order); + + public bool UpdateMarketOrder(MarketOrder order) => _marketOrderRepository.Update(order); + + public MarketOrder GetMarketOrder(int id) => _marketOrderRepository.Get(id); + + public List GetPortfolioEntryOrders(int portfolioEntryId) => + _marketOrderRepository.GetAllByPortfolioEntryId(portfolioEntryId); + + public int DeletePortfolioEntryOrders(int portfolioEntryId) => + _marketOrderRepository.DeletePortfolioEntryOrders(portfolioEntryId); + } +} \ No newline at end of file diff --git a/Services/Services/PortfolioEntryService.cs b/Services/Services/PortfolioEntryService.cs new file mode 100644 index 0000000..4c24b0f --- /dev/null +++ b/Services/Services/PortfolioEntryService.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Model; +using Repository; + +namespace Services +{ + /// + /// A service that is responsible for managing portfolio entries and storing them to a persistent repository. + /// + public interface IPortfolioEntryService + { + /// + /// Creates a new portfolio entry and adds it to a repository + /// + /// Cryptocurrency symbol + /// ID of the portfolio the entry should belong to + /// Created instance of the `PortfolioEntry` class + PortfolioEntry CreatePortfolioEntry(string symbol, int portfolioId); + + /// + /// Deletes the given portfolio entry from a repository + /// + /// Entry to be deleted + /// A flag indicating whether the entry was deleted + bool DeletePortfolioEntry(PortfolioEntry entry); + + /// + /// + /// + /// + /// + int DeletePortfolioEntries(int portfolioId); + + /// + /// Updates the given entry in the repository. The entry with the same ID in the repository is replaced with the one. + /// + /// Updated entry to be stored in the repository + /// A flag indicating whether the portfolio entry was updated + bool UpdatePortfolioEntry(PortfolioEntry entry); + + /// + /// Loads and returns a portfolio entry from the database using the given ID. + /// + /// ID of the portfolio entry to be loaded + /// Found portfolio entry or `null` + PortfolioEntry GetPortfolioEntry(int id); + + /// + /// Returns all portfolio entries that belong to the portfolio identified by the given ID + /// + /// ID of the portfolio whose entries should be found + /// A list of all entries that belong to the given portfolio + List GetPortfolioEntries(int portfolioId); + } + + public class PortfolioEntryServiceImpl : IPortfolioEntryService + { + // dependency on the portfolio entry repository + private readonly IPortfolioEntryRepository _portfolioEntryRepository; + + // dependency on the market order repository + private readonly IMarketOrderService _marketOrderService; + + public PortfolioEntryServiceImpl(IPortfolioEntryRepository portfolioEntryRepository, IMarketOrderService marketOrderService) + { + _portfolioEntryRepository = portfolioEntryRepository; + _marketOrderService = marketOrderService; + } + + public PortfolioEntry CreatePortfolioEntry(string symbol, int portfolioId) + { + // create a new instance of the `PortfolioEntry` class + var portfolioEntry = new PortfolioEntry(symbol, portfolioId); + + // add it to the repository and return it with the generated ID + return portfolioEntry with {Id = _portfolioEntryRepository.Add(portfolioEntry)}; + } + + public bool DeletePortfolioEntry(PortfolioEntry entry) + { + // when deleting a portfolio entry make sure to delete all of its orders + _marketOrderService.DeletePortfolioEntryOrders(entry.Id); + + // finally delete the portfolio entry + return _portfolioEntryRepository.Delete(entry); + } + + public bool UpdatePortfolioEntry(PortfolioEntry entry) => _portfolioEntryRepository.Update(entry); + + public PortfolioEntry GetPortfolioEntry(int id) => _portfolioEntryRepository.Get(id); + + public List GetPortfolioEntries(int portfolioId) => _portfolioEntryRepository.GetAllByPortfolioId(portfolioId); + + public int DeletePortfolioEntries(int portfolioId) + { + // iterate over all entries of the given portfolio + foreach (var portfolioEntry in GetPortfolioEntries(portfolioId)) + { + // delete all orders of each iterated portfolio entry + _marketOrderService.DeletePortfolioEntryOrders(portfolioEntry.Id); + } + + // finally delete entries of the portfolio + return _portfolioEntryRepository.DeletePortfolioEntries(portfolioId); + } + } +} \ No newline at end of file diff --git a/Services/Services/PortfolioService.cs b/Services/Services/PortfolioService.cs index 8cac374..ed50893 100644 --- a/Services/Services/PortfolioService.cs +++ b/Services/Services/PortfolioService.cs @@ -6,48 +6,87 @@ namespace Services { + /// + /// A service that is responsible for managing portfolios and storing them to a persistent repository. + /// public interface IPortfolioService { - Portfolio CreatePortfolio(string name, string description); + /// + /// Creates a new portfolio and stores it to a repository + /// + /// Name of the portfolio + /// Description of the portfolio + /// Currency to be used within the portfolio + /// A created instance of the `Portfolio` class + Portfolio CreatePortfolio(string name, string description, Currency currency); + + /// + /// Deletes the given portfolio from the repository + /// + /// Portfolio to be deleted from the repository + /// A flag indicating whether the portfolio was deleted bool DeletePortfolio(Portfolio portfolio); + + /// + /// Updates the given portfolio in the repository. The portfolio with the same ID in the repository is replaced with the one. + /// + /// Updated portfolio to be stored in the repository + /// A flag indicating whether the portfolio was updated bool UpdatePortfolio(Portfolio portfolio); + + /// + /// Loads and returns a portfolio specified by the given ID from the repository. + /// + /// ID of the portfolio to be loaded from the repository + /// An instance of the `Portfolio` class that was loaded from the repository Portfolio GetPortfolio(int id); + + /// + /// Returns a list of all portfolios present in the repository + /// + /// List of all portfolios present in the repository List GetPortfolios(); } public class PortfolioServiceImpl : IPortfolioService { - private IPortfolioRepository _portfolioRepository; + // dependency on the portfolio repository + private readonly IPortfolioRepository _portfolioRepository; + + // dependency on the portfolio entry service (in order to be able to delete entries when deleting a portfolio) + private readonly IPortfolioEntryService _portfolioEntryService; - public PortfolioServiceImpl(IPortfolioRepository portfolioRepository) + public PortfolioServiceImpl(IPortfolioRepository portfolioRepository, + IPortfolioEntryService portfolioEntryService) { - this._portfolioRepository = portfolioRepository; + _portfolioRepository = portfolioRepository; + _portfolioEntryService = portfolioEntryService; } - public Portfolio CreatePortfolio(string name, string description) + public Portfolio CreatePortfolio(string name, string description, Currency currency) { - var id = _portfolioRepository.Add(new(name, description)); - return new(name, description, id); + // create a new `Portfolio` class instance + var portfolio = new Portfolio(name, description, currency); + return portfolio with + { + Id = _portfolioRepository.Add(portfolio) + }; } public bool DeletePortfolio(Portfolio portfolio) { + // first, delete all portfolio entries that belong to the portfolio being deleted + _portfolioEntryService.DeletePortfolioEntries(portfolio.Id); + + // then finally delete the given portfolio return _portfolioRepository.Delete(portfolio); } - public bool UpdatePortfolio(Portfolio portfolio) - { - throw new NotImplementedException(); - } + public bool UpdatePortfolio(Portfolio portfolio) => _portfolioRepository.Update(portfolio); - public Portfolio GetPortfolio(int id) - { - throw new NotImplementedException(); - } + public Portfolio GetPortfolio(int id) => _portfolioRepository.Get(id); - public List GetPortfolios() - { - throw new NotImplementedException(); - } + public List GetPortfolios() => _portfolioRepository.GetAll(); + } } \ No newline at end of file diff --git a/Services/Services/Services.csproj b/Services/Services/Services.csproj index 9af844f..2e0a31b 100644 --- a/Services/Services/Services.csproj +++ b/Services/Services/Services.csproj @@ -5,6 +5,7 @@ + diff --git a/Services/Services/SummaryService.cs b/Services/Services/SummaryService.cs new file mode 100644 index 0000000..70570bb --- /dev/null +++ b/Services/Services/SummaryService.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Security; +using Model; + +namespace Services +{ + /// + /// A service that is responsible for calculating summaries (total profit, cost,...) of orders, portfolio entries and + /// even portfolios. + /// + public interface ISummaryService + { + /// + /// Absolute change of the of the tracked entity (typically in a USD, EUR,...) + /// + /// + /// Relative change of the of the tracked entity (0 meaning change whatsoever, 1.0 meaning 100% increase in market value, + /// -1.0) meaning -100% decrease in market value + /// + /// + /// Total market value of the tracked entity (entity size multiplied by the current entity value) + /// + /// + /// Cost of the tracked entity (entity size multiplied by the price of the entity at the time it was traded) + /// + public record Summary(decimal AbsoluteChange, decimal RelativeChange, decimal MarketValue, decimal Cost); + + /// + /// Calculates a summary of the given market order + /// + /// Order whose summary should be calculated + /// Market price of the asset to be used when calculating the summary + /// Summary of the given order + public Summary GetMarketOrderSummary(MarketOrder order, decimal assetPrice); + + /// + /// Calculates a summary of the given list of orders of a portfolio entry + /// + /// List of portfolio entries whose summary is to be calculated + /// Market price of the asset to be used when calculating the summary + /// A summary of the given list of orders + public Summary GetPortfolioEntrySummary(List portfolioEntryOrders, decimal assetPrice); + + /// + /// Calculates a summary of the given portfolio entry summaries + /// + /// List of entry summaries whos summary is to be calculated + /// Summary of the given list of summaries + public Summary GetPortfolioSummary(List portfolioEntrySummaries); + } + + public class SummaryServiceImpl : ISummaryService + { + public ISummaryService.Summary GetMarketOrderSummary(MarketOrder order, decimal assetPrice) + { + // order summary does not take into account whether it was a buy or a sell (same as Blockfolio app) + var marketValue = order.Size * assetPrice; + var cost = (order.Size * order.FilledPrice) + order.Fee; + if (cost == 0) + { + // when the cost is zero do not compute anything else + return new(0, 0, 0, 0); + } + + var relativeChange = (marketValue / cost) - new decimal(1); + // absolute change is the difference between the current market value of the order subtracted by order's cost + var absoluteChange = marketValue - cost; + return new(absoluteChange, relativeChange, marketValue, cost); + } + + public ISummaryService.Summary GetAverageOfSummaries(IEnumerable summaries) + { + decimal totalMarketValue = 0; + decimal totalCost = 0; + decimal totalAbsoluteChange = 0; + + // iterate over summaries and sum market value, cost and absolute change + foreach (var summary in summaries) + { + totalMarketValue += summary.MarketValue; + totalCost += summary.Cost; + totalAbsoluteChange += summary.AbsoluteChange; + } + + if (totalCost == 0) + { + // when the cost is zero, do not compute anything else + return new ISummaryService.Summary(0, 0, 0, 0); + } + + decimal totalRelativeChange = (totalMarketValue / totalCost) - 1m; + + return new(totalAbsoluteChange, totalRelativeChange, totalMarketValue, totalCost); + } + + public ISummaryService.Summary GetPortfolioEntrySummary(List portfolioEntryOrders, + decimal assetPrice) + { + decimal totalHoldingSize = 0; + decimal totalSellValue = 0; + decimal totalCost = 0; + decimal totalFee = 0; + + // compute summary of market orders in the same was as the Blockfolio does + portfolioEntryOrders + .ForEach(order => + { + // determine the holding size (negative when the order was a sell) and add it tot he sum of all holdings + totalHoldingSize += order.Size * (order.Buy ? 1 : -1); + // compute the value of the order + var orderValue = order.Size * order.FilledPrice; + + if (!order.Buy) + { + // sum all value of all sell transactions + totalSellValue += orderValue; + } + else + { + // sum cost of all buy transactions + totalCost += orderValue; + } + + totalFee += order.Fee; + }); + + if (totalCost == 0) + { + return new ISummaryService.Summary(0, 0, 0, 0); + } + + // current total holding value is computing by multiplying the total holding size with the given price of the asset + decimal currentTotalHoldingValue = totalHoldingSize * assetPrice; + + // use the same formula as Blockfolio app does to compute the total absolute change + decimal totalAbsoluteChange = currentTotalHoldingValue + totalSellValue - totalCost - totalFee; + decimal totalRelativeChange = totalAbsoluteChange / (totalCost + totalFee); + + return new ISummaryService.Summary(totalAbsoluteChange, totalRelativeChange, currentTotalHoldingValue, + totalCost + totalFee); + } + + // summary of a portfolio is calculated by computing the average of all entry summaries present in it + public ISummaryService.Summary GetPortfolioSummary(List portfolioEntrySummaries) => + GetAverageOfSummaries(portfolioEntrySummaries); + } +} \ No newline at end of file diff --git a/Services/UnitTest1.cs b/Services/UnitTest1.cs deleted file mode 100644 index bff49d3..0000000 --- a/Services/UnitTest1.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Xunit; - -namespace Services -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} diff --git a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs new file mode 100644 index 0000000..35600fb --- /dev/null +++ b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs @@ -0,0 +1,27 @@ +using CryptoStatsSource; +using Xunit; + +namespace Tests.Integration.CryptoStatsSource +{ + public class ResolveNameTest + { + private readonly CoingeckoSource _source; + private readonly CryptocurrencyResolverImpl _resolver; + + public ResolveNameTest() + { + _source = new(); + _resolver = new(_source); + } + + [Fact] + public async void SimpleThreeEntries() + { + Assert.Equal(new("bitcoin", "btc", "Bitcoin"), await _resolver.Resolve("btc")); + Assert.Equal(new ("cardano", "ada", "Cardano"), await _resolver.Resolve("ada")); + Assert.Equal(new ("litecoin", "ltc", "Litecoin"), await _resolver.Resolve("ltc")); + Assert.Equal(new("ethereum", "eth", "Ethereum"), await _resolver.Resolve("eth")); + Assert.Null(await _resolver.Resolve("abcefghbzbzrfoo")); + } + } +} \ No newline at end of file diff --git a/Tests/Integration/CryptoStatsSource/CryptoStatsSource.cs b/Tests/Integration/CryptoStatsSource/CryptoStatsSourceTest.cs similarity index 100% rename from Tests/Integration/CryptoStatsSource/CryptoStatsSource.cs rename to Tests/Integration/CryptoStatsSource/CryptoStatsSourceTest.cs diff --git a/Tests/Integration/Repository/MarketOrderTest.cs b/Tests/Integration/Repository/MarketOrderTest.cs index 0e2c9dc..c30ce59 100644 --- a/Tests/Integration/Repository/MarketOrderTest.cs +++ b/Tests/Integration/Repository/MarketOrderTest.cs @@ -1,29 +1,32 @@ using System; +using System.Collections.Generic; using Database; using Microsoft.Data.Sqlite; using Model; using Repository; using SqlKata.Compilers; using Xunit; -using Utils; namespace Tests.Integration.Repository { public class SqlKataMarketOrderRepositoryFixture : IDisposable { - public SqlKataPortfolioRepository PortfolioRepository; public SqlKataMarketOrderRepository MarketOrderRepository; private SqliteConnection _dbConnection; - public int DefaultPortfolioId; + public int DefaultPortfolioEntryId; + public int SecondaryPortfolioEntryId; public SqlKataMarketOrderRepositoryFixture() { _dbConnection = new SqliteConnection("Data Source=:memory:"); _dbConnection.Open(); var db = new SqlKataDatabase(_dbConnection, new SqliteCompiler()); - this.PortfolioRepository = new(db); + SqlKataPortfolioRepository portfolioRepository = new(db); + SqlKataPortfolioEntryRepository portfolioEntryRepository = new(db); this.MarketOrderRepository = new(db); - DefaultPortfolioId = PortfolioRepository.Add(new("Foo", "Bar")); + var defaultPortfolioId = portfolioRepository.Add(new("Foo", "Bar", Currency.Eur)); + DefaultPortfolioEntryId = portfolioEntryRepository.Add(new("btc", defaultPortfolioId)); + SecondaryPortfolioEntryId = portfolioEntryRepository.Add(new("ltc", defaultPortfolioId)); } public void Dispose() @@ -40,5 +43,258 @@ public MarketOrderRepositoryTest(SqlKataMarketOrderRepositoryFixture marketOrder { this._marketOrderRepositoryFixture = marketOrderRepositoryFixture; } + + [Fact] + public void Add_ReturnsNonZeroId() + { + // arrange + var marketOrder = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: _marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + // act + int id = _marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder); + + // assert + Assert.True(id > 0); + } + + + [Fact] + public void Added_And_Get_AreEqual() + { + // arrange + var marketOrder = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: _marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + // act + int id = _marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder); + // update the added order with the generated id + marketOrder = marketOrder with + { + Id = id + }; + + // assert + Assert.True(id > 0); + var actual = _marketOrderRepositoryFixture.MarketOrderRepository.Get(marketOrder.Id); + Assert.Equal(marketOrder, actual); + } + + [Fact] + public void Added_And_GetAll_AreEqual() + { + // fixture unique to this test + var marketOrderRepositoryFixture = new SqlKataMarketOrderRepositoryFixture(); + + // arrange + var marketOrder1 = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder2 = new MarketOrder(new Decimal(11000.39), 11, new Decimal(1.2), + DateTime.Now.Subtract(TimeSpan.FromSeconds(3600)), true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder3 = new MarketOrder(new Decimal(12000.39), 12, new Decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + // act + marketOrder1 = marketOrder1 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder1) + }; + marketOrder2 = marketOrder2 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder2) + }; + marketOrder3 = marketOrder3 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder3) + }; + + // assert + var loadedPortfolios = marketOrderRepositoryFixture.MarketOrderRepository.GetAll(); + Assert.Equal(3, loadedPortfolios.Count); + Assert.Equal(new List {marketOrder1, marketOrder2, marketOrder3}, loadedPortfolios); + } + + [Fact] + public void AddUpdate_Updates() + { + // arrange + var marketOrder = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: _marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + // act + int id = _marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder); + // update the added order with the generated id and change the filled price, size and buy flag + marketOrder = marketOrder with + { + Id = id, + FilledPrice = 11001, + Size = new decimal(1.3), + Buy = false + }; + _marketOrderRepositoryFixture.MarketOrderRepository.Update(marketOrder); + + // assert + Assert.Equal(marketOrder, _marketOrderRepositoryFixture.MarketOrderRepository.Get(id)); + } + + [Fact] + public void GetAllByPortfolioEntry_Returns_Correct_Orders() + { + // fixture unique to this test + var marketOrderRepositoryFixture = new SqlKataMarketOrderRepositoryFixture(); + + // arrange + var marketOrder1 = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder2 = new MarketOrder(new Decimal(11000.39), 11, new Decimal(1.2), + DateTime.Now.Subtract(TimeSpan.FromSeconds(3600)), true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder3 = new MarketOrder(new Decimal(12000.39), 12, new Decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + var marketOrder4 = new MarketOrder(new Decimal(12005.39), 15, new Decimal(12), + DateTime.Now.Subtract(TimeSpan.FromDays(11)), false, + PortfolioEntryId: marketOrderRepositoryFixture.SecondaryPortfolioEntryId); + + var marketOrder5 = new MarketOrder(new Decimal(12006.39), 16, new Decimal(1.5), + DateTime.Now.Subtract(TimeSpan.FromDays(39)), false, + PortfolioEntryId: marketOrderRepositoryFixture.SecondaryPortfolioEntryId); + + // act + var presumablyEmptyList = + marketOrderRepositoryFixture.MarketOrderRepository.GetAllByPortfolioEntryId(marketOrderRepositoryFixture + .DefaultPortfolioEntryId); + var presumablyEmptyList2 = + marketOrderRepositoryFixture.MarketOrderRepository.GetAllByPortfolioEntryId( + marketOrderRepositoryFixture.SecondaryPortfolioEntryId); + + marketOrder1 = marketOrder1 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder1) + }; + marketOrder2 = marketOrder2 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder2) + }; + marketOrder3 = marketOrder3 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder3) + }; + + marketOrder4 = marketOrder4 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder4) + }; + marketOrder5 = marketOrder5 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder5) + }; + + // assert + var loadedPortfolios = + marketOrderRepositoryFixture.MarketOrderRepository.GetAllByPortfolioEntryId(marketOrderRepositoryFixture + .DefaultPortfolioEntryId); + Assert.Empty(presumablyEmptyList); + Assert.Empty(presumablyEmptyList2); + + Assert.Equal(3, loadedPortfolios.Count); + Assert.Equal(new List {marketOrder1, marketOrder2, marketOrder3}, loadedPortfolios); + + var loadedPortfoliosSecondary = + marketOrderRepositoryFixture.MarketOrderRepository.GetAllByPortfolioEntryId(marketOrderRepositoryFixture + .SecondaryPortfolioEntryId); + Assert.Equal(2, loadedPortfoliosSecondary.Count); + Assert.Equal(new List {marketOrder4, marketOrder5}, loadedPortfoliosSecondary); + } + + [Fact] + public void Delete_Deletes() + { + // fixture unique to this test + var marketOrderRepositoryFixture = new SqlKataMarketOrderRepositoryFixture(); + + // arrange + var marketOrder1 = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder2 = new MarketOrder(new Decimal(11000.39), 11, new Decimal(1.2), + DateTime.Now.Subtract(TimeSpan.FromSeconds(3600)), true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + + // act + marketOrder1 = marketOrder1 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder1) + }; + marketOrder2 = marketOrder2 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder2) + }; + + marketOrderRepositoryFixture.MarketOrderRepository.Delete(marketOrder1); + + // assert + Assert.Null(marketOrderRepositoryFixture.MarketOrderRepository.Get(marketOrder1.Id)); + Assert.Equal(marketOrder2, marketOrderRepositoryFixture.MarketOrderRepository.Get(marketOrder2.Id)); + Assert.Single(marketOrderRepositoryFixture.MarketOrderRepository.GetAll()); + } + + [Fact] + public void DeleteEntryOrders_Deletes_All_Orders() + { + // fixture unique to this test + var marketOrderRepositoryFixture = new SqlKataMarketOrderRepositoryFixture(); + + // arrange + var marketOrder1 = new MarketOrder(new Decimal(10000.39), 10, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder2 = new MarketOrder(new Decimal(11000.39), 11, new Decimal(1.2), + DateTime.Now.Subtract(TimeSpan.FromSeconds(3600)), true, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + var marketOrder3 = new MarketOrder(new Decimal(12000.39), 12, new Decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: marketOrderRepositoryFixture.DefaultPortfolioEntryId); + + var marketOrder4 = new MarketOrder(new Decimal(12005.39), 15, new Decimal(12), + DateTime.Now.Subtract(TimeSpan.FromDays(11)), false, + PortfolioEntryId: marketOrderRepositoryFixture.SecondaryPortfolioEntryId); + + var marketOrder5 = new MarketOrder(new Decimal(12006.39), 16, new Decimal(1.5), + DateTime.Now.Subtract(TimeSpan.FromDays(39)), false, + PortfolioEntryId: marketOrderRepositoryFixture.SecondaryPortfolioEntryId); + + // act + marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder1); + marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder2); + marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder3); + + marketOrder4 = marketOrder4 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder4) + }; + marketOrder5 = marketOrder5 with + { + Id = marketOrderRepositoryFixture.MarketOrderRepository.Add(marketOrder5) + }; + + marketOrderRepositoryFixture.MarketOrderRepository.DeletePortfolioEntryOrders(marketOrderRepositoryFixture + .DefaultPortfolioEntryId); + + // assert + var loadedPortfolios = + marketOrderRepositoryFixture.MarketOrderRepository.GetAllByPortfolioEntryId(marketOrderRepositoryFixture + .DefaultPortfolioEntryId); + + Assert.Empty(loadedPortfolios); + + var loadedPortfoliosSecondary = + marketOrderRepositoryFixture.MarketOrderRepository.GetAllByPortfolioEntryId(marketOrderRepositoryFixture + .SecondaryPortfolioEntryId); + Assert.Equal(2, loadedPortfoliosSecondary.Count); + Assert.Equal(new List {marketOrder4, marketOrder5}, loadedPortfoliosSecondary); + } } } \ No newline at end of file diff --git a/Tests/Integration/Repository/PortfolioEntryTest.cs b/Tests/Integration/Repository/PortfolioEntryTest.cs index 2855bd1..d475ff6 100644 --- a/Tests/Integration/Repository/PortfolioEntryTest.cs +++ b/Tests/Integration/Repository/PortfolioEntryTest.cs @@ -1,11 +1,11 @@ using System; +using System.Collections.Generic; using Database; using Microsoft.Data.Sqlite; using Model; using Repository; using SqlKata.Compilers; using Xunit; -using Utils; namespace Tests.Integration.Repository { @@ -15,6 +15,7 @@ public class SqlKataPortfolioEntryRepositoryFixture : IDisposable public SqlKataPortfolioEntryRepository PortfolioEntryRepository; private SqliteConnection _dbConnection; public int DefaultPortfolioId; + public int SecondaryPortfolioId; public SqlKataPortfolioEntryRepositoryFixture() { @@ -23,7 +24,8 @@ public SqlKataPortfolioEntryRepositoryFixture() var db = new SqlKataDatabase(_dbConnection, new SqliteCompiler()); this.PortfolioRepository = new(db); this.PortfolioEntryRepository = new(db); - DefaultPortfolioId = PortfolioRepository.Add(new("Foo", "Bar")); + DefaultPortfolioId = PortfolioRepository.Add(new("Foo", "Bar", Currency.Czk)); + SecondaryPortfolioId = PortfolioRepository.Add(new("Bar", "Bar", Currency.Czk)); } public void Dispose() @@ -74,16 +76,114 @@ public void Added_And_Get_AreEqual() _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(portfolioEntry.Id)); } + [Fact] + public void Added_And_GetAll_AreEqual() + { + // fixture unique to this test + var portfolioEntryRepositoryFixture = new SqlKataPortfolioEntryRepositoryFixture(); + + // arrange + var portfolioEntry1 = new PortfolioEntry("btc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var portfolioEntry2 = new PortfolioEntry("ada", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var portfolioEntry3 = new PortfolioEntry("ltc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + + // act + portfolioEntry1 = portfolioEntry1 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry1) + }; + portfolioEntry2 = portfolioEntry2 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry2) + }; + portfolioEntry3 = portfolioEntry3 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry3) + }; + + // assert + var loadedPortfolios = portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAll(); + Assert.Equal(3, loadedPortfolios.Count); + Assert.Equal(new List {portfolioEntry1, portfolioEntry2, portfolioEntry3}, + loadedPortfolios); + } + + [Fact] + public void GetAllByPortfolioId_Returns_Correct_Entries() + { + // fixture unique to this test + var portfolioEntryRepositoryFixture = new SqlKataPortfolioEntryRepositoryFixture(); + + // arrange + var portfolioEntry1 = new PortfolioEntry("btc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var portfolioEntry2 = new PortfolioEntry("ada", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var portfolioEntry3 = new PortfolioEntry("ltc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + + var portfolioEntry4 = new PortfolioEntry("btc", portfolioEntryRepositoryFixture.SecondaryPortfolioId); + var portfolioEntry5 = new PortfolioEntry("eth", portfolioEntryRepositoryFixture.SecondaryPortfolioId); + + // act + var presumablyEmptyList = + portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAllByPortfolioId( + portfolioEntryRepositoryFixture.DefaultPortfolioId); + var presumablyEmptyList2 = + portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAllByPortfolioId( + portfolioEntryRepositoryFixture.SecondaryPortfolioId); + + portfolioEntry1 = portfolioEntry1 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry1) + }; + portfolioEntry2 = portfolioEntry2 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry2) + }; + portfolioEntry3 = portfolioEntry3 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry3) + }; + + portfolioEntry4 = portfolioEntry4 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry4) + }; + portfolioEntry5 = portfolioEntry5 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry5) + }; + + // assert + var loadedPortfolios = + portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAllByPortfolioId( + portfolioEntryRepositoryFixture.DefaultPortfolioId); + + Assert.Empty(presumablyEmptyList); + Assert.Empty(presumablyEmptyList2); + + Assert.Equal(new List {portfolioEntry1, portfolioEntry2, portfolioEntry3}, + loadedPortfolios); + + var loadedPortfoliosSecondaryPortfolio = + portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAllByPortfolioId( + portfolioEntryRepositoryFixture.SecondaryPortfolioId); + Assert.Equal(2, loadedPortfoliosSecondaryPortfolio.Count); + Assert.Equal(new List {portfolioEntry4, portfolioEntry5}, + loadedPortfoliosSecondaryPortfolio); + } + [Fact] public void AddUpdate_Updates() { + // fixture unique to this test + var portfolioEntryRepositoryFixture = new SqlKataPortfolioEntryRepositoryFixture(); + // arrange - var btcEntry = new PortfolioEntry("btc", _portfolioEntryRepositoryFixture.DefaultPortfolioId); - var ethEntry = new PortfolioEntry("eth", _portfolioEntryRepositoryFixture.DefaultPortfolioId); + var btcEntry = new PortfolioEntry("btc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var ethEntry = new PortfolioEntry("eth", portfolioEntryRepositoryFixture.DefaultPortfolioId); // act - int btcId = _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(btcEntry); - int ethId = _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(ethEntry); + int btcId = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(btcEntry); + int ethId = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(ethEntry); ethEntry = ethEntry with { Id = ethId @@ -95,10 +195,77 @@ public void AddUpdate_Updates() // change it's name Symbol = "ltc" }; - _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Update(btcEntry); + portfolioEntryRepositoryFixture.PortfolioEntryRepository.Update(btcEntry); + + Assert.Equal(btcEntry, portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(btcEntry.Id)); + Assert.Equal(ethEntry, portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(ethEntry.Id)); + } + + [Fact] + public void Delete_Deletes() + { + // arrange + var firstEntry = new PortfolioEntry("btc", _portfolioEntryRepositoryFixture.DefaultPortfolioId); + var secondEntry = new PortfolioEntry("ltc", _portfolioEntryRepositoryFixture.DefaultPortfolioId); + + // act + firstEntry = firstEntry with + { + Id = _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(firstEntry) + }; + + secondEntry = secondEntry with + { + Id = _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(secondEntry) + }; + _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Delete(firstEntry); + + // assert + Assert.Null(_portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(firstEntry.Id)); + Assert.Equal(secondEntry, _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(secondEntry.Id)); + Assert.Single(_portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAll()); + } + + [Fact] + public void DeletePortfolioEntries_Deletes_Correct_Entries() + { + // fixture unique to this test + var portfolioEntryRepositoryFixture = new SqlKataPortfolioEntryRepositoryFixture(); + + // arrange + var portfolioEntry1 = new PortfolioEntry("btc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var portfolioEntry2 = new PortfolioEntry("ada", portfolioEntryRepositoryFixture.DefaultPortfolioId); + var portfolioEntry3 = new PortfolioEntry("ltc", portfolioEntryRepositoryFixture.DefaultPortfolioId); + + var portfolioEntry4 = new PortfolioEntry("btc", portfolioEntryRepositoryFixture.SecondaryPortfolioId); + var portfolioEntry5 = new PortfolioEntry("eth", portfolioEntryRepositoryFixture.SecondaryPortfolioId); + + // act + portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry1); + portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry2); + portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry3); + + portfolioEntry4 = portfolioEntry4 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry4) + }; + portfolioEntry5 = portfolioEntry5 with + { + Id = portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(portfolioEntry5) + }; + portfolioEntryRepositoryFixture.PortfolioEntryRepository.DeletePortfolioEntries( + portfolioEntryRepositoryFixture.DefaultPortfolioId); - Assert.Equal(btcEntry, _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(btcEntry.Id)); - Assert.Equal(ethEntry, _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(ethEntry.Id)); + // assert + + Assert.Empty(portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAllByPortfolioId(portfolioEntryRepositoryFixture.DefaultPortfolioId)); + + var loadedPortfoliosSecondaryPortfolio = + portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAllByPortfolioId( + portfolioEntryRepositoryFixture.SecondaryPortfolioId); + Assert.Equal(2, loadedPortfoliosSecondaryPortfolio.Count); + Assert.Equal(new List {portfolioEntry4, portfolioEntry5}, + loadedPortfoliosSecondaryPortfolio); } } } \ No newline at end of file diff --git a/Tests/Integration/Repository/PortfolioTest.cs b/Tests/Integration/Repository/PortfolioTest.cs index c00f26d..b40b148 100644 --- a/Tests/Integration/Repository/PortfolioTest.cs +++ b/Tests/Integration/Repository/PortfolioTest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Security.Cryptography; using Database; using Microsoft.Data.Sqlite; @@ -30,22 +31,21 @@ public void Dispose() public class PortfolioTest : IClassFixture { - private SqlKataPortfolioRepositoryFixture _portfolioFixture; + private SqlKataPortfolioRepositoryFixture _portfolioRepositoryFixture; - public PortfolioTest(SqlKataPortfolioRepositoryFixture portfolioFixture) + public PortfolioTest(SqlKataPortfolioRepositoryFixture portfolioRepositoryFixture) { - this._portfolioFixture = portfolioFixture; + this._portfolioRepositoryFixture = portfolioRepositoryFixture; } [Fact] public void Add_ReturnsNonZeroId() { // arrange - var portfolio = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet"); + var portfolio = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet", Currency.Eur); // act - int id = _portfolioFixture.PortfolioRepository.Add(new Portfolio("My new portfolio", - "Lorem ipsum dolor sit amet")); + int id = _portfolioRepositoryFixture.PortfolioRepository.Add(portfolio); // assert Assert.True(id > 0); @@ -55,11 +55,11 @@ public void Add_ReturnsNonZeroId() public void Added_And_Get_AreEqual() { // arrange - var portfolio = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet"); + var portfolio = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet", Currency.Czk); // act - int id = _portfolioFixture.PortfolioRepository.Add(portfolio); - var loaded = _portfolioFixture.PortfolioRepository.Get(id); + int id = _portfolioRepositoryFixture.PortfolioRepository.Add(portfolio); + var loaded = _portfolioRepositoryFixture.PortfolioRepository.Get(id); portfolio = portfolio with { Id = loaded.Id @@ -69,16 +69,38 @@ public void Added_And_Get_AreEqual() Assert.True(id > 0); Assert.Equal(portfolio, loaded); } - + + [Fact] + public void Added_And_GetAll_AreEqual() + { + // fixture unique to this test + var portfolioRepositoryFixture = new SqlKataPortfolioRepositoryFixture(); + + // arrange + var portfolio1 = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet", Currency.Czk); + var portfolio2 = new Portfolio("My second portfolio", "Lorem ipsum dolor sit amet", Currency.Eur); + var portfolio3 = new Portfolio("My third portfolio", "Lorem ipsum dolor sit amet", Currency.Usd); + + // act + portfolio1 = portfolio1 with {Id = portfolioRepositoryFixture.PortfolioRepository.Add(portfolio1)}; + portfolio2 = portfolio2 with {Id = portfolioRepositoryFixture.PortfolioRepository.Add(portfolio2)}; + portfolio3 = portfolio3 with {Id = portfolioRepositoryFixture.PortfolioRepository.Add(portfolio3)}; + + // assert + var loadedPortfolios = portfolioRepositoryFixture.PortfolioRepository.GetAll(); + Assert.Equal(3, loadedPortfolios.Count); + Assert.Equal(new List {portfolio1, portfolio2, portfolio3}, loadedPortfolios); + } + [Fact] public void AddUpdate_Updates() { // arrange - var template = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet"); + var template = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet", Currency.Usd); // act - int firstId = _portfolioFixture.PortfolioRepository.Add(template); - int secondId = _portfolioFixture.PortfolioRepository.Add(template); + int firstId = _portfolioRepositoryFixture.PortfolioRepository.Add(template); + int secondId = _portfolioRepositoryFixture.PortfolioRepository.Add(template); var secondPortfolio = template with { Id = secondId @@ -88,13 +110,40 @@ public void AddUpdate_Updates() // update the first entry Id = firstId, // change it's name - Name = "Foo Portfolio" + Name = "Foo Portfolio", + Currency = Currency.Eur }; - _portfolioFixture.PortfolioRepository.Update(firstPortfolio); - - Assert.Equal(firstPortfolio, _portfolioFixture.PortfolioRepository.Get(firstPortfolio.Id)); - Assert.Equal(secondPortfolio, _portfolioFixture.PortfolioRepository.Get(secondPortfolio.Id)); + _portfolioRepositoryFixture.PortfolioRepository.Update(firstPortfolio); + + Assert.Equal(firstPortfolio, _portfolioRepositoryFixture.PortfolioRepository.Get(firstPortfolio.Id)); + Assert.Equal(secondPortfolio, _portfolioRepositoryFixture.PortfolioRepository.Get(secondPortfolio.Id)); } + [Fact] + public void Delete_Deletes() + { + // fixture unique to this test + var portfolioRepositoryFixture = new SqlKataPortfolioRepositoryFixture(); + + // arrange + var firstPortfolio = new Portfolio("My new portfolio", "Lorem ipsum dolor sit amet", Currency.Usd); + var secondPortfolio = new Portfolio("My second new portfolio", "Lorem ipsum dolor sit amet", Currency.Eur); + + // act + firstPortfolio = firstPortfolio with + { + Id = portfolioRepositoryFixture.PortfolioRepository.Add(firstPortfolio) + }; + secondPortfolio = secondPortfolio with + { + Id = portfolioRepositoryFixture.PortfolioRepository.Add(secondPortfolio) + }; + portfolioRepositoryFixture.PortfolioRepository.Delete(firstPortfolio); + + // assert + Assert.Null(portfolioRepositoryFixture.PortfolioRepository.Get(firstPortfolio.Id)); + Assert.Equal(secondPortfolio, portfolioRepositoryFixture.PortfolioRepository.Get(secondPortfolio.Id)); + Assert.Equal(1, portfolioRepositoryFixture.PortfolioRepository.GetAll().Count); + } } } \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 76c06dc..1bbf02d 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -26,6 +27,7 @@ + diff --git a/Tests/Unit/Service/MarketOrderServiceTest.cs b/Tests/Unit/Service/MarketOrderServiceTest.cs new file mode 100644 index 0000000..d0f4fe5 --- /dev/null +++ b/Tests/Unit/Service/MarketOrderServiceTest.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Model; +using Moq; +using Repository; +using Services; +using Xunit; + +namespace Tests.Unit.Service +{ + public class MarketOrderServiceTest + { + [Fact] + public void Create_CallsRepository() + { + // arrange + var marketOrderToBeAdded = new MarketOrder(new decimal(12000.39), 12, new decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: 1); + + var repositoryMock = new Mock(); + repositoryMock.Setup(x => + x.Add(It.Is(marketOrder => marketOrder == marketOrderToBeAdded))).Returns(1); + var service = new MarketOrderServiceImpl(repositoryMock.Object); + + // act + var marketOrder = service.CreateMarketOrder(new decimal(12000.39), 12, new decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, 1); + + // assert + Assert.Equal(marketOrderToBeAdded with {Id = 1}, marketOrder); + } + + [Fact] + public void Get_CallsRepository() + { + // arrange + var marketOrderPresentInRepository = new MarketOrder(new decimal(12000.39), 12, new decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: 1); + var repositoryMock = new Mock(); + repositoryMock.Setup(x => x.Get(It.Is(id => id == 1))).Returns(marketOrderPresentInRepository); + var service = new MarketOrderServiceImpl(repositoryMock.Object); + + // act + var marketOrder = service.GetMarketOrder(1); + + // assert + Assert.Equal(marketOrderPresentInRepository, marketOrder); + } + + [Fact] + public void GetPortfolioEntries_CallsRepository() + { + // arrange + var entriesList = new List + { + new(new Decimal(10000.39), 11, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: 1), + new(new Decimal(10000.39), 12, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: 1), + new(new Decimal(10000.39), 13, new Decimal(1.1), DateTime.Now, true, + PortfolioEntryId: 2), + new(new Decimal(10000.11), 14, new Decimal(1.1), DateTime.Now, false, + PortfolioEntryId: 1), + }; + + var repositoryMock = new Mock(); + repositoryMock.Setup(x => x.GetAllByPortfolioEntryId(It.Is(id => id == 1))).Returns( + new List() + { + entriesList[0], entriesList[1], entriesList[3] + }); + var service = new MarketOrderServiceImpl(repositoryMock.Object); + + // act + var entriesFetched = service.GetPortfolioEntryOrders(1); + + // assert + Assert.Equal(new List + { + entriesList[0], entriesList[1], entriesList[3] + }, entriesFetched); + } + + [Fact] + public void Update_CallsRepository() + { + // arrange + var marketOrderToBeUpdated = new MarketOrder(new decimal(12000.39), 12, new decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: 1); + var repositoryMock = new Mock(); + repositoryMock.Setup(x => x.Update(It.IsAny())).Returns(true); + var service = new MarketOrderServiceImpl(repositoryMock.Object); + + // act + var updated = service.UpdateMarketOrder(marketOrderToBeUpdated); + + // assert + Assert.True(updated); + } + + [Fact] + public void Delete_CallsRepository() + { + // arrange + var marketOrderToBeDeleted = new MarketOrder(new decimal(12000.39), 12, new decimal(1.3), + DateTime.Now.Subtract(TimeSpan.FromDays(30)), false, + PortfolioEntryId: 1); + var repositoryMock = new Mock(); + repositoryMock.Setup(x => x.Delete(It.IsAny())).Returns(true); + var service = new MarketOrderServiceImpl(repositoryMock.Object); + + // act + var delete = service.DeleteMarketOrder(marketOrderToBeDeleted); + + // assert + Assert.True(delete); + } + + [Fact] + public void DeletePortfolioEntries_CallsRepository() + { + // arrange + var portfolioEntryId = 15; + var repositoryMock = new Mock(); + var service = new MarketOrderServiceImpl(repositoryMock.Object); + + // act + service.DeletePortfolioEntryOrders(portfolioEntryId); + + // assert + repositoryMock.Verify(x => x.DeletePortfolioEntryOrders(It.Is(id => id == portfolioEntryId))); + } + } +} \ No newline at end of file diff --git a/Tests/Unit/Service/PortfolioEntryServiceTest.cs b/Tests/Unit/Service/PortfolioEntryServiceTest.cs new file mode 100644 index 0000000..8e030e0 --- /dev/null +++ b/Tests/Unit/Service/PortfolioEntryServiceTest.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using Model; +using Moq; +using Repository; +using Services; +using Xunit; + +namespace Tests.Unit.Service +{ + public class PortfolioEntryServiceTest + { + [Fact] + public void Create_CallsRepository() + { + // arrange + var portfolioEntryToBeAdded = new PortfolioEntry("btc", 1); + var repositoryMock = new Mock(); + var marketOrderServiceMock = new Mock(); + repositoryMock.Setup(x => + x.Add(It.Is(portfolioEntry => portfolioEntry == portfolioEntryToBeAdded))).Returns(1); + var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); + + // act + var portfolioEntry = service.CreatePortfolioEntry("btc", 1); + + // assert + Assert.Equal(portfolioEntryToBeAdded with {Id = 1}, portfolioEntry); + } + + [Fact] + public void Get_CallsRepository() + { + // arrange + var portfolioEntryPresentInRepository = new PortfolioEntry("btc", 1); + var repositoryMock = new Mock(); + var marketOrderServiceMock = new Mock(); + repositoryMock.Setup(x => x.Get(It.Is(id => id == 1))).Returns(portfolioEntryPresentInRepository); + var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); + + // act + var portfolioEntry = service.GetPortfolioEntry(1); + + // assert + Assert.Equal(portfolioEntryPresentInRepository, portfolioEntry); + } + + [Fact] + public void GetPortfolioEntries_CallsRepository() + { + // arrange + var entriesList = new List + { + new("btc", 1, 1), + new("ada", 2, 2), + new("btc", 3, 3), + new("ltc", 1, 4) + }; + + var repositoryMock = new Mock(); + var marketOrderServiceMock = new Mock(); + repositoryMock.Setup(x => x.GetAllByPortfolioId(It.Is(id => id == 1))).Returns( + new List() + { + entriesList[0], entriesList[3] + }); + var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); + + // act + var entriesFetched = service.GetPortfolioEntries(1); + + // assert + Assert.Equal(new List + { + entriesList[0], entriesList[3] + }, entriesFetched); + } + + [Fact] + public void Update_CallsRepository() + { + // arrange + var entryToBeUpdated = new PortfolioEntry("btc", 1, 1); + var repositoryMock = new Mock(); + var marketOrderServiceMock = new Mock(); + repositoryMock.Setup(x => x.Update(It.IsAny())).Returns(true); + var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); + + // act + var updated = service.UpdatePortfolioEntry(entryToBeUpdated); + + // assert + Assert.True(updated); + } + + [Fact] + public void Delete_CallsRepository_And_MarketOrderService() + { + // arrange + var entryToBeDeleted = new PortfolioEntry("btc", 1, 1); + var repositoryMock = new Mock(); + var marketOrderServiceMock = new Mock(); + repositoryMock.Setup(x => x.Delete(It.IsAny())).Returns(true); + var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); + + // act + var delete = service.DeletePortfolioEntry(entryToBeDeleted); + + // assert + marketOrderServiceMock.Verify(x => x.DeletePortfolioEntryOrders(It.Is(id => id == entryToBeDeleted.Id))); + Assert.True(delete); + } + + [Fact] + public void DeletePortfolioEntryOrders_CallsRepository_And_MarketOrderService() + { + // arrange + var portfolioId = 1; + var portfolioEntryList = new List() + { + new ("btc", portfolioId, 10), + new ("eth", portfolioId, 11), + new ("ltc", portfolioId, 12), + }; + + var repositoryMock = new Mock(); + var marketOrderServiceMock = new Mock(); + // setup that deleting portfolioId returns the prepared collection of entries + repositoryMock.Setup(x => x.GetAllByPortfolioId(It.Is(id => id == portfolioId))) + .Returns(portfolioEntryList); + // setup that deleting portfolio entries returns the number of entries present in the prepared collection + repositoryMock.Setup(x => x.DeletePortfolioEntries(It.Is(id => id == portfolioId))).Returns(portfolioEntryList.Count); + var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); + + // act + var delete = service.DeletePortfolioEntries(portfolioId); + + // assert + foreach (var portfolioEntry in portfolioEntryList) + { + marketOrderServiceMock.Verify(x => x.DeletePortfolioEntryOrders(It.Is(id => id == portfolioEntry.Id))); + } + Assert.True(delete == portfolioEntryList.Count); + } + } +} \ No newline at end of file diff --git a/Tests/Unit/Service/PortfolioServiceTest.cs b/Tests/Unit/Service/PortfolioServiceTest.cs new file mode 100644 index 0000000..f324d22 --- /dev/null +++ b/Tests/Unit/Service/PortfolioServiceTest.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using Model; +using Moq; +using Repository; +using Services; +using Xunit; + +namespace Tests.Unit.Service +{ + public class PortfolioServiceTest + { + [Fact] + public void Create_CallsRepository() + { + // arrange + var portfolioToBeAdded = new Portfolio("Foo", "Bar", Currency.Eur, -1); + var repositoryMock = new Mock(); + var portfolioEntryServiceMock = new Mock(); + repositoryMock.Setup(x => x.Add(It.Is(portfolio => portfolio == portfolioToBeAdded))).Returns(1); + var service = new PortfolioServiceImpl(repositoryMock.Object, portfolioEntryServiceMock.Object); + + // act + var portfolio = service.CreatePortfolio("Foo", "Bar", Currency.Eur); + + // assert + Assert.Equal(portfolioToBeAdded with {Id = 1}, portfolio); + } + + [Fact] + public void Get_CallsRepository() + { + // arrange + var portfolioToBeAdded = new Portfolio("Foo", "Bar", Currency.Eur, 1); + var repositoryMock = new Mock(); + repositoryMock.Setup(x => x.Get(It.IsAny())).Returns(portfolioToBeAdded); + var portfolioEntryServiceMock = new Mock(); + var service = new PortfolioServiceImpl(repositoryMock.Object, portfolioEntryServiceMock.Object); + + // act + var portfolio = service.GetPortfolio(1); + + // assert + Assert.Equal(portfolioToBeAdded, portfolio); + } + + [Fact] + public void GetPortfolios_CallsRepository() + { + // arrange + var portfolioList = new List + { + new("My new portfolio", "Lorem ipsum dolor sit amet", Currency.Czk), + new("My second portfolio", "Lorem ipsum dolor sit amet", Currency.Eur), + new("My third portfolio", "Lorem ipsum dolor sit amet", Currency.Usd) + }; + + var repositoryMock = new Mock(); + var portfolioEntryServiceMock = new Mock(); + repositoryMock.Setup(x => x.GetAll()).Returns(portfolioList); + var service = new PortfolioServiceImpl(repositoryMock.Object, portfolioEntryServiceMock.Object); + + // act + var portfolioListFetched = service.GetPortfolios(); + + // assert + Assert.Equal(portfolioList, portfolioListFetched); + } + + [Fact] + public void Update_CallsRepository() + { + // arrange + var portfolioToBeUpdated = new Portfolio("Foo", "Bar", Currency.Eur, 1); + var repositoryMock = new Mock(); + var portfolioEntryServiceMock = new Mock(); + repositoryMock.Setup(x => x.Update(It.IsAny())).Returns(true); + var service = new PortfolioServiceImpl(repositoryMock.Object, portfolioEntryServiceMock.Object); + + // act + var updated = service.UpdatePortfolio(portfolioToBeUpdated); + + // assert + Assert.True(updated); + } + + [Fact] + public void Delete_CallsRepository_And_EntryService() + { + // arrange + var portfolioToBeDeleted = new Portfolio("Foo", "Bar", Currency.Eur, 1); + var repositoryMock = new Mock(); + repositoryMock.Setup(x => x.Delete(It.IsAny())).Returns(true); + var portfolioEntryServiceMock = new Mock(); + var service = new PortfolioServiceImpl(repositoryMock.Object, portfolioEntryServiceMock.Object); + + // act + var delete = service.DeletePortfolio(portfolioToBeDeleted); + + // assert + portfolioEntryServiceMock.Verify(x => x.DeletePortfolioEntries(It.Is(id => id == portfolioToBeDeleted.Id))); + Assert.True(delete); + } + } +} \ No newline at end of file diff --git a/Tests/Unit/Service/SummaryServiceTest.cs b/Tests/Unit/Service/SummaryServiceTest.cs new file mode 100644 index 0000000..6e2f055 --- /dev/null +++ b/Tests/Unit/Service/SummaryServiceTest.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Model; +using Moq; +using Services; +using Xunit; + +namespace Tests.Unit.Service +{ + public class SummaryServiceTest + { + [Fact] + public void GetMarketOrderSummary_WithoutFee_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + MarketOrder order = new(10000m, 0m, 1m, DateTime.Now, true); + var summary = service.GetMarketOrderSummary(order, 20000m); + + Assert.Equal(new ISummaryService.Summary( + 10000, + 1, + 20000, + 10000 + ), summary); + } + + [Fact] + public void GetMarketOrderSummary_WithFee_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + MarketOrder order = new(10000m, 1m, 1m, DateTime.Now, true); + var summary = service.GetMarketOrderSummary(order, 20000m); + + Assert.Equal(new ISummaryService.Summary( + 9999, + (20000m / (10000m + 1m)) - 1m, + 20000, + 10001 + ), summary); + } + + [Fact] + public void GetMarketOrderSummary_WithoutFee_InLoss_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + MarketOrder order = new(10000m, 0m, 1m, DateTime.Now, true); + var summary = service.GetMarketOrderSummary(order, 5000m); + + Assert.Equal(new ISummaryService.Summary( + -5000m, + -0.5m, + 5000, + 10000 + ), summary); + } + + + [Fact] + public void GetMarketOrderSummary_WithFee_InLoss_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + MarketOrder order = new(10000m, 1m, 1m, DateTime.Now, true); + var summary = service.GetMarketOrderSummary(order, 5000m); + + Assert.Equal(new ISummaryService.Summary( + -5001, + (5000m / (10000m + 1m)) - 1m, + 5000, + 10001 + ), summary); + } + + [Fact] + public void GetMarketOrderSummary_ZeroCost_Returns_Zero_Summary() + { + var service = new SummaryServiceImpl(); + MarketOrder order = new(10000m, 0m, 0m, DateTime.Now, true); + var summary = service.GetMarketOrderSummary(order, 5000m); + + Assert.Equal(new ISummaryService.Summary( + 0, + 0, + 0, + 0 + ), summary); + } + + // PortfolioEntrySummary tests + + [Fact] + public void GetPortfolioEntrySummary_InProfit_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioEntrySummary(new() + { + new(10000m, 0m, 1m, DateTime.Now, true), + new(20000m, 0m, 1m, DateTime.Now, true) + }, 40000); + + Assert.Equal(new ISummaryService.Summary( + 30000m + 20000m, + (80000m / 30000m) - 1m, + 80000m, + 30000 + ), summary); + } + + [Fact] + public void GetPortfolioEntrySummary_WithFee_InProfit_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioEntrySummary(new() + { + new(10000m, 1m, 1m, DateTime.Now, true), + new(20000m, 5m, 1m, DateTime.Now, true) + }, 40000); + Assert.Equal(new ISummaryService.Summary( + 30000m + 20000m - 6, + ((80000m) / (30000m + 6m)) - 1m, + 80000m, + 30006 + ), summary); + } + + [Fact] + public void GetPortfolioEntrySummary_InProfit_WithSell_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioEntrySummary(new() + { + new(10000m, 0m, 1m, DateTime.Now, true), + new(20000m, 0m, 1m, DateTime.Now, true), + new(30000m, 0m, 0.5m, DateTime.Now, false) + }, 40000); + + Assert.Equal(new ISummaryService.Summary( + 1.5m * 40000m + 15000m - 30000m, + ((1.5m * 40000m + 15000m - 30000m) / 30000m), + 1.5m * 40000m, + 30000 + ), summary); + } + + [Fact] + public void GetPortfolioEntrySummary_ZeroCost_Returns_Zero_Summary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioEntrySummary(new() + { + new(10000m, 0m, 0m, DateTime.Now, true), + }, 40000); + + Assert.Equal(new ISummaryService.Summary( + 0m, + 0m, + 0m, + 0m + ), summary); + } + + [Fact] + public void GetPortfolioEntrySummary_WithFee_InProfit_WithSell_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioEntrySummary(new() + { + new(10000m, 1m, 1m, DateTime.Now, true), + new(20000m, 5m, 1m, DateTime.Now, true) + }, 40000); + Assert.Equal(new ISummaryService.Summary( + 30000m + 20000m - 6, + (80000m / (30000m + 6m)) - 1m, + 80000m, + 30006 + ), summary); + } + + // Portfolio summary tests + + [Fact] + public void GetPortfolioSummary_Returns_Correct_Summary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioSummary(new() + { + new(10000m, 1, 20000m, 10000), + new(2000m, 2, 3000m, 1000), + }); + Assert.Equal( + new ISummaryService.Summary(12000m, (23000m / 11000m) - 1m, 23000m, 11000m), + summary + ); + } + + [Fact] + public void GetPortfolioSummary_ZeroCost_Returns_ZeroSummary() + { + var service = new SummaryServiceImpl(); + var summary = service.GetPortfolioSummary(new() + { + new(10000m, 1, 20000m, 0), + new(2000m, 2, 3000m, 0), + }); + + Assert.Equal( + new ISummaryService.Summary(0m, 0m, 0m, 0m), + summary + ); + } + } +} \ No newline at end of file diff --git a/Utils/CurrencyUtils.cs b/Utils/CurrencyUtils.cs new file mode 100644 index 0000000..a3e2a07 --- /dev/null +++ b/Utils/CurrencyUtils.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Model; + +namespace Utils +{ + public class CurrencyUtils + { + /// + /// Returns a label of the given currency + /// + /// Currency whose label should be returned + /// Label of the given currency + public static string GetCurrencyLabel(Currency currency) + { + switch (currency) + { + case Currency.Czk: + return "CZK"; + case Currency.Eur: + return "EUR"; + case Currency.Usd: + return "USD"; + } + + return "UNDEFINED"; + } + + /// + /// Format's the given value and currency due to currency's formatting rules + /// + /// Value to be formatted + /// Currency to be used + /// A string containing both the value and the currency symbol in the correct format + public static string Format(decimal value, Currency currency) + { + var valueStr = DecimalUtils.FormatTwoDecimalPlaces(Math.Abs(value)); + var output = ""; + switch (currency) + { + case Currency.Czk: + output = $"{valueStr},- Kč"; + break; + case Currency.Eur: + output = $"€{valueStr}"; + break; + case Currency.Usd: + output = $"${valueStr}"; + break; + default: + output = "UNDEFINED_CURRENCY"; + break; + } + + if (value < 0) + { + output = "-" + output; + } + + return output; + } + + /// + /// Format's the given value and currency due to currency's formatting rules. Adds the plus symbol when the value + /// is positive. + /// + /// Value to be formatted + /// Currency to be used + /// A string containing both the value and the currency symbol in the correct format + public static string FormatWithPlusSign(decimal value, Currency currency) => + (value > 0 ? "+" : "") + Format(value, currency); + } +} \ No newline at end of file diff --git a/Utils/DateUtils.cs b/Utils/DateUtils.cs deleted file mode 100644 index a228b5f..0000000 --- a/Utils/DateUtils.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace Utils -{ - public static class DateUtils - { - /** - * Converts unix timestamp to a DateTime object - */ - public static DateTime UnixTimeStampToDateTime(double unixTimeStamp) - { - // Unix timestamp is seconds past epoch - System.DateTime dtDateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); - dtDateTime = dtDateTime.AddSeconds(unixTimeStamp).ToLocalTime(); - return dtDateTime; - } - } -} \ No newline at end of file diff --git a/Utils/DecimalUtils.cs b/Utils/DecimalUtils.cs new file mode 100644 index 0000000..5c5a3f5 --- /dev/null +++ b/Utils/DecimalUtils.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; + +namespace Utils +{ + public static class DecimalUtils + { + // define white space separator number format, it is to be used in all format methods on this class + private static readonly NumberFormatInfo WhitespaceSeparatorNf = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone(); + + static DecimalUtils() + { + // make the number format use whitespace as thousands separator + WhitespaceSeparatorNf.NumberGroupSeparator = " "; + } + + public static string FormatTrimZeros(decimal value) => value.ToString("#,0.#############", WhitespaceSeparatorNf); + + public static string FormatTwoDecimalPlaces(decimal value) => value.ToString("#,0.00", WhitespaceSeparatorNf); + + public static string FormatFiveDecimalPlaces(decimal value) => value.ToString("#,0.00000", WhitespaceSeparatorNf); + + public static string FormatTwoDecimalPlacesWithPlusSign(decimal value) => + (value > 0 ? "+" : "") + FormatTwoDecimalPlaces(value); + } +} \ No newline at end of file diff --git a/Utils/EnumUtils.cs b/Utils/EnumUtils.cs new file mode 100644 index 0000000..849bba9 --- /dev/null +++ b/Utils/EnumUtils.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Utils +{ + public static class EnumUtils + { + /// + /// Returns all possible values of the given enumerable + /// + /// Enumerable whose values are to be returned + /// List of possible values of the given enumerable + public static List GetEnumList() where TEnum : Enum + => ((TEnum[])Enum.GetValues(typeof(TEnum))).ToList(); + } +} \ No newline at end of file diff --git a/Utils/Utils.csproj b/Utils/Utils.csproj index cbfa581..e0fa99c 100644 --- a/Utils/Utils.csproj +++ b/Utils/Utils.csproj @@ -4,4 +4,8 @@ net5.0 + + + + diff --git a/WebFrontend/.config/dotnet-tools.json b/WebFrontend/.config/dotnet-tools.json new file mode 100644 index 0000000..1ec89f7 --- /dev/null +++ b/WebFrontend/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "electronnet.cli": { + "version": "11.5.1", + "commands": [ + "electronize" + ] + } + } +} \ No newline at end of file diff --git a/WebFrontend/App.razor b/WebFrontend/App.razor index 3a2af53..6033d80 100644 --- a/WebFrontend/App.razor +++ b/WebFrontend/App.razor @@ -1,10 +1,37 @@ - + + + + + - -

Sorry, there's nothing at this address.

-
+ + +
+
+ +
+
+ + There is nothing at this address. + +
+
+
+
+
+
+ + +@code +{ + // to be used as application's theme + MatTheme appTheme = new() + { + Primary = MatThemeColors.BlueGrey._500.Value + }; +} \ No newline at end of file diff --git a/WebFrontend/Data/WeatherForecast.cs b/WebFrontend/Data/WeatherForecast.cs deleted file mode 100644 index 832dcb1..0000000 --- a/WebFrontend/Data/WeatherForecast.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace ServerSideBlazor.Data -{ - public class WeatherForecast - { - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string Summary { get; set; } - } -} diff --git a/WebFrontend/Data/WeatherForecastService.cs b/WebFrontend/Data/WeatherForecastService.cs deleted file mode 100644 index 0063e93..0000000 --- a/WebFrontend/Data/WeatherForecastService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace ServerSideBlazor.Data -{ - public class WeatherForecastService: IDisposable - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - public async Task GetForecastAsync(DateTime startDate) - { - await Task.Delay(1000); - - var rng = new Random(); - return Enumerable.Range(1, 20).Select(index => new WeatherForecast - { - Date = startDate.AddDays(index), - TemperatureC = rng.Next(-20, 55), - Summary = Summaries[rng.Next(Summaries.Length)] - }).ToArray(); - } - - public void Dispose() - { - Console.WriteLine("Disposing forecast..."); - } - } -} diff --git a/WebFrontend/Pages/Counter.razor b/WebFrontend/Pages/Counter.razor deleted file mode 100644 index 500b7af..0000000 --- a/WebFrontend/Pages/Counter.razor +++ /dev/null @@ -1,16 +0,0 @@ -@page "/counter" - -

Counter

- -

Current count: @currentCount

- -Click me - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/WebFrontend/Pages/EditMarketOrder.razor b/WebFrontend/Pages/EditMarketOrder.razor new file mode 100644 index 0000000..2cbca14 --- /dev/null +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -0,0 +1,91 @@ +@page "/editmarketorder/{orderId:int}" +@using Model +@using Services +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntrySerivce +@inject IMarketOrderService MarketOrderService +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster +@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager + + + + +
+
+
+
+ Back + + +

Edit a market order

+ +
+
+
+
+
+
+ + +@code +{ + // ID of the order to be edited + [Parameter] + public int OrderId { get; set; } + + // order form model + private OrderForm.OrderFormModel _initialOrderFormModel; + + // portfolio the order will belong to + private Portfolio _activePortfolio; + + // portfolio entry the order will belong to + private PortfolioEntry _activeEntry; + + // edited order + private MarketOrder _activeMarketOrder; + + protected override void OnInitialized() + { + // fetch the order to be edited + _activeMarketOrder = MarketOrderService.GetMarketOrder(OrderId); + + if (_activeEntry == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + _activeEntry = PortfolioEntrySerivce.GetPortfolioEntry(_activeMarketOrder.PortfolioEntryId); + _activePortfolio = PortfolioService.GetPortfolio(_activeEntry.PortfolioId); + + // initialize the order form model + _initialOrderFormModel = new(); + _initialOrderFormModel.Fee = _activeMarketOrder.Fee; + _initialOrderFormModel.Size = _activeMarketOrder.Size; + _initialOrderFormModel.FilledPrice = _activeMarketOrder.FilledPrice; + _initialOrderFormModel.OrderDate = _activeMarketOrder.Date; + _initialOrderFormModel.SellOrder = !_activeMarketOrder.Buy; + } + + private void OnCreateOrderFormSubmit(OrderForm.OrderFormModel formFormModel) + { + // update the order + MarketOrderService.UpdateMarketOrder(_activeMarketOrder with { + FilledPrice = formFormModel.FilledPrice, + Fee = formFormModel.Fee, + Size = formFormModel.Size, + Date = formFormModel.OrderDate, + Buy = !formFormModel.SellOrder + }); + Toaster.Add("Order successfully edited", MatToastType.Success, "", ""); + + // navigate back to the portfolio entry detail + NavigationManager.NavigateTo($"/entries/{_activeEntry.Id}"); + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/EditPortfolio.razor b/WebFrontend/Pages/EditPortfolio.razor new file mode 100644 index 0000000..ed5d490 --- /dev/null +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -0,0 +1,93 @@ +@page "/editportfolio/{portfolioId:int}" +@using Model +@using Services +@using Utils +@inject IPortfolioService PortfolioService +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster +@inject NavigationManager NavigationManager + + + + +
+
+
+
+ + Back + + + +

Edit portfolio

+ + +
+
+
+
+
+
+ + +@code +{ + // ID of the portfolio to be edited + [Parameter] + public int PortfolioId { get; set; } + + // model of the form + private readonly PortfolioForm.PortfolioFormModel _formFormModel = new(Currency.Usd); + + // currently edited portfolio + private Portfolio _activePortfolio; + + protected override void OnInitialized() + { + // find the portfolio + _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); + + if (_activePortfolio == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + // update the form model + _formFormModel.Name = _activePortfolio.Name; + _formFormModel.Description = _activePortfolio.Description; + } + + private void OnCreateFormSubmitted(PortfolioForm.PortfolioFormModel formFormModel) + { + // update the portfolio + PortfolioService.UpdatePortfolio(_activePortfolio with { + Name = formFormModel.Name, + Description = formFormModel.Description + }); + // reset the form + formFormModel.Reset(); + Toaster.Add("Portfolio successfully edited", MatToastType.Success, "", ""); + + // navigate back to the portfolio detail + NavigationManager.NavigateTo($"/portfolios/{_activePortfolio.Id}"); + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/FetchData.razor b/WebFrontend/Pages/FetchData.razor deleted file mode 100644 index 6eb5731..0000000 --- a/WebFrontend/Pages/FetchData.razor +++ /dev/null @@ -1,71 +0,0 @@ -@page "/fetchdata" - -@using ServerSideBlazor.Data -@using CryptoStatsSource.model -@using CryptoStatsSource -@inject WeatherForecastService ForecastService -@inject ICryptoStatsSource CryptoStatsService; - -

Weather forecast

- -

This component demonstrates fetching data from a service.

- -@if (forecasts == null) -{ - -} -else -{ - - - Date - Temp. (C) - Temp. (F) - Summary - - - @context.Date.ToShortDateString() - @context.TemperatureC - @context.TemperatureF - @context.Summary - - -} - -@if (entries == null) -{ - -} -else -{ - - - Symbol - Name - Current Price ($) - Market Cap ($) - Price Change Last 24h ($) - Price Change Last 24h (%) - - - @context.Symbol - @context.Name - @context.CurrentPrice - @context.MarketCap - @context.PriceChange24H - @context.PriceChangePercentage24H - - -} - -@code { - WeatherForecast[] forecasts; - MarketEntry[] entries; - - protected override async Task OnInitializedAsync() - { - forecasts = await ForecastService.GetForecastAsync(DateTime.Now); - entries = (await CryptoStatsService.GetMarketEntries("usd", "bitcoin", "litecoin", "cardano")).ToArray(); - - } -} diff --git a/WebFrontend/Pages/Index.razor b/WebFrontend/Pages/Index.razor index 4fbc698..aa50350 100644 --- a/WebFrontend/Pages/Index.razor +++ b/WebFrontend/Pages/Index.razor @@ -1,5 +1,158 @@ @page "/" +@using Services +@using Utils +@using Model +@inject NavigationManager NavigationManager +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntryService +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster -

Hello, world!

+ +
+
+ +
+
+ Portfolios + @if (_portfoliosWithEntries == null) + { + + } + else if (_portfoliosWithEntries.Count < 1) + { + + No portfolios were found. +
+ +
+
+ } + else + { + @foreach (var portfolioWithEntries in _portfoliosWithEntries) + { + + +
+
+
+
+ + + @portfolioWithEntries.Item1.Name + + + +
+
+ + +
+
+
+
+
+ + @if (portfolioWithEntries.Item2.Count > 0) + { + @foreach (var entry in portfolioWithEntries.Item2) + { + + } + } + else + { + + } +
+
+
+ } + } +
+
+
+
+ + + +@code +{ + // list of portfolios with entries mapped to them + private List>> _portfoliosWithEntries; + + protected record PortfolioEntryRow(string symbol, decimal currentPrice, decimal relativeChange, decimal percentage); + + protected override void OnInitialized() + { + LoadPortfolios(); + } + + private void LoadPortfolios() + { + _portfoliosWithEntries = PortfolioService.GetPortfolios().Select( + portfolio => new Tuple>( + portfolio, + PortfolioEntryService.GetPortfolioEntries(portfolio.Id) + ) + ).ToList(); + } + + private void EditPortfolio(Portfolio activePortfolioItem1) + { + NavigationManager.NavigateTo($"/editportfolio/{activePortfolioItem1.Id}"); + } + + private async void DeletePortfolio(Portfolio portfolio) + { + // let user confirm whether he wants to delete the portfolio + var result = await MatDialogService.ConfirmAsync("Do you really wish to delete this portfolio including all of it's portfolio entries and market orders?"); + if (result) + { + // delete portfolio + PortfolioService.DeletePortfolio(portfolio); + // reload the portfolio list + LoadPortfolios(); + // refresh the UI + StateHasChanged(); + Toaster.Add($"Portfolio \"{portfolio.Name}\" sucessfully deleted", MatToastType.Info, "", ""); + } + } + + private void AddNewEntryToPortfolio(Portfolio portfolio) + { + NavigationManager.NavigateTo($"/newportfolioentry/{portfolio.Id}"); + } + + private void ViewPortfolio(Portfolio portfolio) + { + NavigationManager.NavigateTo($"/portfolios/{portfolio.Id}"); + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor new file mode 100644 index 0000000..9c18a8d --- /dev/null +++ b/WebFrontend/Pages/NewMarketOrder.razor @@ -0,0 +1,70 @@ +@page "/newmarketorder/{entryId:int}" +@using Services +@using Model +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntrySerivce +@inject IMarketOrderService MarketOrderService +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster +@inject NavigationManager NavigationManager + + + + +
+
+
+
+ Back + + +

Create a new market order

+ +
+
+
+
+
+
+ + +@code +{ + [Parameter] + public int EntryId { get; set; } + + // the active portfolio + private Portfolio _activePortfolio = new("", "", Currency.Usd); + + // entry to which the entry will be added + private PortfolioEntry _activeEntry = new("", 1); + + protected override void OnInitialized() + { + // load the portfolio entry + _activeEntry = PortfolioEntrySerivce.GetPortfolioEntry(EntryId); + + if (_activeEntry == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + // load the portfolio the entry belongs to + _activePortfolio = PortfolioService.GetPortfolio(_activeEntry.PortfolioId); + } + + private void OnCreateOrderFormSubmit(OrderForm.OrderFormModel formFormModel) + { + // create the market order + MarketOrderService.CreateMarketOrder(formFormModel.FilledPrice, formFormModel.Fee, formFormModel.Size, formFormModel.OrderDate, !formFormModel.SellOrder, _activeEntry.Id); + // reset the form model + formFormModel.Reset(); + // notify user + Toaster.Add("New order successfully added", MatToastType.Success, "", ""); + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/NewPortfolio.razor b/WebFrontend/Pages/NewPortfolio.razor new file mode 100644 index 0000000..ba03c26 --- /dev/null +++ b/WebFrontend/Pages/NewPortfolio.razor @@ -0,0 +1,52 @@ +@page "/newportfolio" +@using Services +@using Utils +@using Model +@inject IPortfolioService PortfolioService +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster +@inject NavigationManager NavigationManager + + + + +
+
+
+
+ + + Back + + + +

New portfolio

+ + +
+
+
+
+
+
+ + +@code +{ + private void OnCreateFormSubmitted(PortfolioForm.PortfolioFormModel formFormModel) + { + // create the portfolio + PortfolioService.CreatePortfolio(formFormModel.Name, formFormModel.Description, formFormModel.SelectedCurrency); + // reset the model + formFormModel.Reset(); + // notify the user + Toaster.Add("New portfolio successfully added", MatToastType.Success, "", ""); + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/PortfolioDetail.razor b/WebFrontend/Pages/PortfolioDetail.razor new file mode 100644 index 0000000..b253afb --- /dev/null +++ b/WebFrontend/Pages/PortfolioDetail.razor @@ -0,0 +1,239 @@ +@page "/portfolios/{portfolioId:int}" +@using Services +@using Utils +@using CryptoStatsSource +@using Model +@inject NavigationManager NavigationManager +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntryService +@inject IMarketOrderService MarketOrderService; +@inject ICryptoStatsSource CryptoStatsSource; +@inject ISummaryService SummaryService; +@inject ICryptocurrencyResolver CryptocurrencyResolver; + + +
+
+
+
+ BackPortfolio Detail + @if (_activePortfolio != null) + { + + +
+ + + @_activePortfolio.Name + + + + @_activePortfolio.Description +
+ +
+
+ @if (_portfolioSummary != null) + { +
+ +
+
+ +
+ } + else + { + + } +
+
+
+
+
+ + @if (_portfolioEntryRows == null) + { + + } + else if (_portfolioEntryRows.Count > 0) + { + + + Coin + Price + Price change (1h) + Holdings + + + @context.Symbol.ToUpper() + +
@(CurrencyUtils.Format(context.CurrentPrice, _activePortfolio.Currency))
+ + +
@DecimalUtils.FormatTwoDecimalPlaces(context.RelativeChange)%
+ + @(CurrencyUtils.Format(context.MarketValue, _activePortfolio.Currency)) (@(DecimalUtils.FormatTwoDecimalPlaces(context.Percentage))%) +
+
+ } + else + { + + No portfolio entries were found. +
+ +
+
+ } + } + else + { + + } +
+
+
+
+ + + +@code +{ + // id of the portfolio whose detail should be shown + [Parameter] + public int PortfolioId { get; set; } + + // portfolio whose detail should be shown + private Portfolio _activePortfolio; + + // summary of the portfolio + private ISummaryService.Summary _portfolioSummary; + + // entries of the portfolio + private List _activePortfolioEntries; + + // rows of the portfolio entry table + private List _portfolioEntryRows; + + protected record PortfolioEntryRow(string Symbol, decimal CurrentPrice, decimal RelativeChange, decimal Percentage, decimal AbsoluteChange, decimal MarketValue, int EntryId); + + protected override void OnInitialized() + { + _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); + if (_activePortfolio == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + _activePortfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); + _loadEntryInfo(); + } + + + private async void _loadEntryInfo() + { + // resolve names of all portfolio entries + await CryptocurrencyResolver.Refresh(); + var portfolioCryptocurrencyEntries = await Task.WhenAll( + _activePortfolioEntries.Select( + async entry => (await CryptocurrencyResolver.Resolve(entry.Symbol))) + ); + + // fetch market entries of all entries of the portfolio + var marketEntries = await CryptoStatsSource.GetMarketEntries( + CurrencyUtils.GetCurrencyLabel(_activePortfolio.Currency).ToLower(), portfolioCryptocurrencyEntries.Select(c => c.Id).ToArray() + ); + + // create a dictionary where a symbol is mapped to a market entry + var symbolsToMarketEntries = marketEntries.ToDictionary(entry => entry.Symbol, entry => entry); + + // compute portfolio entry summaries + var entrySummaries = _activePortfolioEntries.Select( + portfolioEntry => + { + // find the evaluation of the entry's asset + var marketEntry = symbolsToMarketEntries.GetValueOrDefault(portfolioEntry.Symbol); + + // fetch all orders of the currently iterated portfolio entry + var entryMarketOrders = MarketOrderService.GetPortfolioEntryOrders(portfolioEntry.Id); + + // compute the summary of the entry based on market orders + return SummaryService.GetPortfolioEntrySummary(entryMarketOrders, marketEntry.CurrentPrice); + } + ).ToList(); + + // compute portfolio's summary based on summaries of it's entries + _portfolioSummary = SummaryService.GetPortfolioSummary(entrySummaries); + + // if the cost of the summary is zero, set the relative change to zero + if (_portfolioSummary.Cost == 0) + { + _portfolioSummary = _portfolioSummary with { + RelativeChange = 0 + }; + } + + // compute the total value of the portfolio by summing market values of all entries + var portfolioTotalMarketValue = entrySummaries.Sum(summary => summary.MarketValue); + + // create portfolio entry table rows + _portfolioEntryRows = entrySummaries.Zip(_activePortfolioEntries).Select( + tuple => new PortfolioEntryRow( + // symbol of the portfolio entry + tuple.Second.Symbol, + // current price of the entry's asset + symbolsToMarketEntries[tuple.Second.Symbol].CurrentPrice, + // asset's price change since the last 24h + new decimal(symbolsToMarketEntries[tuple.Second.Symbol].PriceChangePercentage24H ?? 0), + // percentage within the portfolio entry + portfolioTotalMarketValue > 0 ? (tuple.First.MarketValue / portfolioTotalMarketValue) * 100 : 0, + // absolute change within the portfolio entry + tuple.First.AbsoluteChange, + // market value + tuple.First.MarketValue, + // pass the entry id + tuple.Second.Id + ) + ).ToList(); + + // update the UI + StateHasChanged(); + } + + public void SelectionChangedEvent(object row) + { + if (row != null) + { + // entry row has been clicked, open it's detail + NavigationManager.NavigateTo($"entries/{((PortfolioEntryRow) row).EntryId}"); + } + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/PortfolioEntryDetail.razor b/WebFrontend/Pages/PortfolioEntryDetail.razor new file mode 100644 index 0000000..0546ac7 --- /dev/null +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -0,0 +1,354 @@ +@page "/entries/{entryId:int}" +@using Model +@using Services +@using Utils +@using CryptoStatsSource +@using CryptoStatsSource.model +@inject NavigationManager NavigationManager +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntryService +@inject IMarketOrderService MarketOrderService +@inject ICryptocurrencyResolver CryptocurrencyResolver +@inject ICryptoStatsSource CryptoStatsSource +@inject ISummaryService SummaryService + + +
+
+
+
+ BackPortfolio Entry + + + @if(_activePortfolioEntry != null) + { +
+ + + @if (_portfolioCryptocurrencyEntry != null) + { + @_portfolioCryptocurrencyEntry.Name + } + else + { + + } + + @if (_currentEntryAssetMarketEntry != null) + { + 1 @_activePortfolioEntry.Symbol.ToUpper() = @CurrencyUtils.Format(_currentEntryAssetMarketEntry.CurrentPrice, _activePortfolio.Currency) + } + else + { + + } + + +
+ + + @if (_entrySummary != null) + { +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+ } + else + { + + } + +
+ } + else + { + + } +
+
+ @if (_tableRowsItems == null) + { + + } + else if (_tableRowsItems.Count == 0) + { + No market orders were found.
+ } + else + { + + + Date + Size + Price + Market Value + Change + Actions + + + +
@(String.Format("{0:d.M.yyyy HH:mm:ss}", context.Item1.Date))
+ + + @if (@context.Item1.Buy) + { +
@context.Item1.Size @_activePortfolioEntry.Symbol.ToUpper()
+ } + else + { +
-@context.Item1.Size @_activePortfolioEntry.Symbol.ToUpper()
+ } + + +
@(CurrencyUtils.Format(context.Item1.FilledPrice, _activePortfolio.Currency))
+ + +
@CurrencyUtils.Format(context.Item2.MarketValue, _activePortfolio.Currency)
+ + +
@CurrencyUtils.Format(context.Item2.AbsoluteChange, _activePortfolio.Currency) (@(DecimalUtils.FormatTwoDecimalPlaces(context.Item2.RelativeChange * 100))%)
+ + + + + +
+
+ } +
+
+
+
+ + + + Market Order Detail + + @if (_orderToBeShown != null) + { +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ } +
+ + Close + +
+ +@code +{ + // ID of the entry whose detail should be displayed + [Parameter] + public int EntryId { get; set; } + + // portfolio the entry belongs to + private Portfolio _activePortfolio; + + // the entry to be displayed + private PortfolioEntry _activePortfolioEntry; + + // market entry of the portfolio entry + private MarketEntry _currentEntryAssetMarketEntry; + + // cryptocurrency of the portfolio entry + private Cryptocurrency _portfolioCryptocurrencyEntry; + + // summary of the entry + private ISummaryService.Summary _entrySummary; + + // total holdings of the active entry + private decimal _totalHoldings; + + // flag indicating whether order's detail is open + private bool _orderDetailDialogIsOpen; + + // order whose detail should be displayed + private Tuple _orderToBeShown; + + // list of market orders mapped to summaries + private List> _tableRowsItems; + + protected override void OnInitialized() + { + // get the portfolio entry + _activePortfolioEntry = PortfolioEntryService.GetPortfolioEntry(EntryId); + + if (_activePortfolioEntry == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + // get the entry's portfolio + _activePortfolio = PortfolioService.GetPortfolio(_activePortfolioEntry.PortfolioId); + } + + protected override async Task OnInitializedAsync() + { + // resolve the name of the cryptocurrency (using the symbol) + _portfolioCryptocurrencyEntry = await CryptocurrencyResolver.Resolve(_activePortfolioEntry.Symbol); + + await UpdateEntrySummary(); + } + + private void SetEntryLoading() + { + _currentEntryAssetMarketEntry = null; + _tableRowsItems = null; + _entrySummary = null; + StateHasChanged(); + } + + private async Task UpdateEntrySummary() + { + + // fetch the price of the entry's asset + _currentEntryAssetMarketEntry = (await CryptoStatsSource.GetMarketEntries( + CurrencyUtils.GetCurrencyLabel(_activePortfolio.Currency).ToLower(), + _portfolioCryptocurrencyEntry.Id + ))[0]; + + // get all orders of the portfolio entry + var entryOrders = MarketOrderService.GetPortfolioEntryOrders(_activePortfolioEntry.Id); + + // compute summaries of all orders in the entry + var entrySummaries = entryOrders.Select(order => + SummaryService.GetMarketOrderSummary(order, _currentEntryAssetMarketEntry.CurrentPrice)); + + // compute the total holdings by adding all buy order sizes and subtracting sell order sizes + _totalHoldings = entryOrders.Sum(order => order.Size * (order.Buy ? 1 : -1)); + + // zip entry orders and summaries into a table rows + _tableRowsItems = entryOrders.Zip(entrySummaries) + .Select(tuple => new Tuple(tuple.First, tuple.Second)).ToList(); + + // compute suummary of this entry + _entrySummary = SummaryService.GetPortfolioEntrySummary(entryOrders, _currentEntryAssetMarketEntry.CurrentPrice); + } + + public void EditMarketOrder(MarketOrder order) + { + NavigationManager.NavigateTo($"/editmarketorder/{order.Id}"); + } + + async void DeletePortfolio(MarketOrder order) + { + // let the user confirm that he really wants to delete an entry + var result = await MatDialogService.ConfirmAsync("Do you really wish to delete this market order?"); + if (result) + { + // delete the order + MarketOrderService.DeleteMarketOrder(order); + SetEntryLoading(); + // update the summary of the entry + await UpdateEntrySummary(); + // refressh the UI + StateHasChanged(); + Toaster.Add("Order successfully deleted", MatToastType.Success, "", ""); + } + } + + void ShowOrderDetail(Tuple order) + { + _orderToBeShown = order; + _orderDetailDialogIsOpen = true; + StateHasChanged(); + } + + void HideOrderDetail() + { + _orderToBeShown = null; + _orderDetailDialogIsOpen = false; + StateHasChanged(); + } + + private void SelectionChangedEvent(object obj) + { + if (obj != null) + { + // order has been clicked, show its detail + ShowOrderDetail((Tuple) obj) ; + } + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/PortfolioEntryManagement.razor b/WebFrontend/Pages/PortfolioEntryManagement.razor new file mode 100644 index 0000000..47624c5 --- /dev/null +++ b/WebFrontend/Pages/PortfolioEntryManagement.razor @@ -0,0 +1,176 @@ +@page "/newportfolioentry/{portfolioId:int}" +@using Model +@using Services +@using Utils +@using System.ComponentModel.DataAnnotations +@using CryptoStatsSource +@using CryptoStatsSource.model +@using Repository +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntryService +@inject ICryptoStatsSource CryptoStatsSource; +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster +@inject NavigationManager NavigationManager + +
+
+
+
+ Back to portfolioManage entries of @_portfolio.Name + + @if (_availableCryptocurrenciesWithUsage == null) + { + + } + else + { + @if (_availableCryptocurrenciesWithUsage.Count > 0) + { + + + + Symbol + Name + + + + @context.Item1.Symbol + +
@context.Item1.Name
+ + @if (context.Item2) + { + + + + } + else + { + + + + } +
+
+ } + else + { + No cryptocurrencies match the symbol "@CryptocurrencyFilter" + } + } +
+
+
+
+ + +@code +{ + public string CryptocurrencyFilter + { + get => _cryptocurrencyFilter; + set + { + _cryptocurrencyFilter = value; + // when setting the cryptocurrency symbol filter, do filter the list of available cryptos + FilterCurrenciesBySymbol(value); + this.StateHasChanged(); + } + } + + private void FilterCurrenciesBySymbol(string value) + { + // filter by symbol + _filteredCryptocurrencies = _availableCryptocurrencies.FindAll(c => c.Symbol.Contains(value)); + UpdateAvailableCryptocurrencies(_filteredCryptocurrencies); + } + + private string _cryptocurrencyFilter; + + [Parameter] + public int PortfolioId { get; set; } + + // the portfolio whose entries are being managed + private Portfolio _portfolio; + + // existing entries of the portfolio + private List _portfolioEntries; + + // list of available cryptocurrencies + private List _availableCryptocurrencies; + + // list of filtered cryptocurrencies + private List _filteredCryptocurrencies; + + // cryptocurrencies mapped to a flag indicating whether it is present in the portfolio or not + private List> _availableCryptocurrenciesWithUsage; + + protected override void OnInitialized() + { + // load the portfolio using the specified ID + _portfolio = PortfolioService.GetPortfolio(PortfolioId); + + if (_portfolio == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + // load all entries of the portfolio + _portfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); + } + + protected override async Task OnInitializedAsync() + { + // find all available cryptocurrencies + _availableCryptocurrencies = (await CryptoStatsSource.GetAvailableCryptocurrencies()) + // workaround till Coingecko removes binance-peg entries + .Where(c => !c.Id.Contains("binance-peg")).ToList(); + _filteredCryptocurrencies = _availableCryptocurrencies; + UpdateAvailableCryptocurrencies(_availableCryptocurrencies); + } + + private void UpdateAvailableCryptocurrencies(List availableCryptocurrencies) + { + var entriesSymbols = _portfolioEntries.Select(e => e.Symbol.ToLower()); + // map available cryptocurrencies to a flag indicating whether they are used in the given portfolio + // order by the flag and then by symbol length + _availableCryptocurrenciesWithUsage = availableCryptocurrencies.Select( + c => new Tuple(c, !entriesSymbols.Contains(c.Symbol.ToLower())) + ).OrderBy(c => c.Item2).ThenBy(c => c.Item1.Symbol.Length).ToList(); + } + + private void OnAddCurrencyClicked(Cryptocurrency cryptocurrency) + { + // create a new portfolio entry + var entry = PortfolioEntryService.CreatePortfolioEntry(cryptocurrency.Symbol, PortfolioId); + _portfolioEntries.Add(entry); + + // update the UI + UpdateAvailableCryptocurrencies(_filteredCryptocurrencies); + StateHasChanged(); + + // notify the user + Toaster.Add($"{cryptocurrency.Symbol.ToUpper()} entry successfully added to {_portfolio.Name}.", MatToastType.Success, "", ""); + } + + private async void OnDeleteCurrencyClicked(Cryptocurrency cryptocurrency) + { + // let user confirm whether he wants to delete the entry + var result = await MatDialogService.ConfirmAsync($"Do you really wish to delete {cryptocurrency.Symbol.ToUpper()} entry including all of it's market entries?"); + if (result) + { + // find the entry + var entry = _portfolioEntries.Find(entry => entry.Symbol == cryptocurrency.Symbol); + // delete the entry + PortfolioEntryService.DeletePortfolioEntry(entry); + _portfolioEntries.Remove(entry); + // update the UI + UpdateAvailableCryptocurrencies(_filteredCryptocurrencies); + StateHasChanged(); + Toaster.Add($"{cryptocurrency.Symbol.ToUpper()} entry successfully deleted from {_portfolio.Name}.", MatToastType.Success, "", ""); + } + } +} \ No newline at end of file diff --git a/WebFrontend/Pages/ResourceNotFound.razor b/WebFrontend/Pages/ResourceNotFound.razor new file mode 100644 index 0000000..5f1d6c9 --- /dev/null +++ b/WebFrontend/Pages/ResourceNotFound.razor @@ -0,0 +1,50 @@ +@page "/notfound" +@using Services +@inject NavigationManager NavigationManager +@inject IPortfolioService PortfolioService +@inject IPortfolioEntryService PortfolioEntryService +@inject IMatDialogService MatDialogService +@inject IMatToaster Toaster + + +
+
+ +
+
+ + Resource not found +
+ +
+
+
+
+
+
diff --git a/WebFrontend/Pages/_Host.cshtml b/WebFrontend/Pages/_Host.cshtml index d83405b..0029941 100644 --- a/WebFrontend/Pages/_Host.cshtml +++ b/WebFrontend/Pages/_Host.cshtml @@ -10,7 +10,7 @@ - WebFrontend + Crypto portfolio tracker diff --git a/WebFrontend/Program.cs b/WebFrontend/Program.cs index 2b30c9d..a56bf8c 100644 --- a/WebFrontend/Program.cs +++ b/WebFrontend/Program.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ElectronNET.API; namespace WebFrontend { @@ -18,6 +19,10 @@ public static void Main(string[] args) public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseElectron(args); + webBuilder.UseStartup(); + }); } } \ No newline at end of file diff --git a/WebFrontend/Properties/launchSettings.json b/WebFrontend/Properties/launchSettings.json index 8926612..6088d10 100644 --- a/WebFrontend/Properties/launchSettings.json +++ b/WebFrontend/Properties/launchSettings.json @@ -8,6 +8,13 @@ } }, "profiles": { + "Electron.NET App": { + "commandName": "Executable", + "executablePath": "electronize", + "commandLineArgs": "start", + "workingDirectory": "." + }, + "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, @@ -18,7 +25,7 @@ "ServerSideBlazor": { "commandName": "Project", "dotnetRunMessages": "true", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/WebFrontend/Shared/LabelDecimalValue.razor b/WebFrontend/Shared/LabelDecimalValue.razor new file mode 100644 index 0000000..38b1ff8 --- /dev/null +++ b/WebFrontend/Shared/LabelDecimalValue.razor @@ -0,0 +1,61 @@ + + +@if (!Smaller) +{ + @Label + @Value +} +else +{ + @Label + @Value +} + +@code { + + [Parameter] + public String Value { get; set; } + + [Parameter] + public String Label { get; set; } + + [Parameter] + public bool ValueColorBasedOnValue { get; set; } = false; + + [Parameter] + public bool Positive { get; set; } = true; + + [Parameter] + public bool Smaller { get; set; } + +} \ No newline at end of file diff --git a/WebFrontend/Shared/MainLayout.razor b/WebFrontend/Shared/MainLayout.razor index 26bf2e0..e572797 100644 --- a/WebFrontend/Shared/MainLayout.razor +++ b/WebFrontend/Shared/MainLayout.razor @@ -2,56 +2,19 @@ - - - Material Design Blazor Template (Server-side) - - - + + Crypto Portfolio Tracker - - - - - - - - - - - - - - - - + -
+
@Body
- -@code -{ - bool _navMenuOpened = true; - - void ButtonClicked() - { - _navMenuOpened = !_navMenuOpened; - } - - protected void ModelDrawerHiddenChanged(bool hidden) - { - if (!hidden) - { - _navMenuOpened = false; - } - } -} \ No newline at end of file diff --git a/WebFrontend/Shared/NavMenu.razor b/WebFrontend/Shared/NavMenu.razor deleted file mode 100644 index 975aac8..0000000 --- a/WebFrontend/Shared/NavMenu.razor +++ /dev/null @@ -1,5 +0,0 @@ - -   Home -   Counter -   Fetch Data - \ No newline at end of file diff --git a/WebFrontend/Shared/NavMenu.razor.css b/WebFrontend/Shared/NavMenu.razor.css deleted file mode 100644 index acc5f9f..0000000 --- a/WebFrontend/Shared/NavMenu.razor.css +++ /dev/null @@ -1,62 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.oi { - width: 2rem; - font-size: 1.1rem; - vertical-align: text-top; - top: -2px; -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.25); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } -} diff --git a/WebFrontend/Shared/OrderForm.razor b/WebFrontend/Shared/OrderForm.razor new file mode 100644 index 0000000..8ff3500 --- /dev/null +++ b/WebFrontend/Shared/OrderForm.razor @@ -0,0 +1,128 @@ +@using Utils +@using System.ComponentModel.DataAnnotations +@using Model + + +

+ + + +

+ +

+ + + +

+ +

+ + + +

+ +

+
+ Order date +

+

+ + +

+

+ +

+ + + + @if (Edit) + { + Save + } + else + { + Create + } + + + + + + + +
+ +@code { + // model of the form + [Parameter] + public OrderFormModel FormModel { get; set; } = new (); + + // callback when the form is submitted + [Parameter] public EventCallback OnSubmitEventHandler { get; set; } + + // currency of the transaction + [Parameter] public Currency Currency { get; set; } + + // traded cryptocurrency symbol + [Parameter] public string Symbol { get; set; } + + // flag indicating whether the order should be created or an existing one should be edited + [Parameter] public bool Edit { get; set; } + + + public class OrderFormModel + { + // transaction filled price + [Required] + [CustomValidation(typeof(OrderFormModel), nameof(NonZeroValue))] + public decimal FilledPrice { get; set; } + + // size of the transaction + [Required] + [CustomValidation(typeof(OrderFormModel), nameof(NonZeroValue))] + public decimal Size { get; set; } + + // fee for the transaction + [Required] + [CustomValidation(typeof(OrderFormModel), nameof(NonNegativeValue))] + public decimal Fee { get; set; } + + // date at which the transaction was performed + [Required] public DateTime OrderDate = DateTime.Now; + + // a flag indicating whether the transaction was a sell + [Required] public bool SellOrder; + + public void Reset() + { + FilledPrice = 0m; + Size = 0m; + Fee = 0m; + OrderDate = DateTime.Now; + SellOrder = false; + } + + public static ValidationResult NonZeroValue(decimal value, ValidationContext vc) + { + return value > 0 + ? ValidationResult.Success + : new ValidationResult("Value must be non-zero", new[] {vc.MemberName}); + } + + public static ValidationResult NonNegativeValue(decimal value, ValidationContext vc) + { + return value >= 0 + ? ValidationResult.Success + : new ValidationResult("Value must be positive", new[] {vc.MemberName}); + } + } + + private async void OnFormSubmitted() + { + await OnSubmitEventHandler.InvokeAsync(FormModel); + FormModel.Reset(); + } +} \ No newline at end of file diff --git a/WebFrontend/Shared/PortfolioForm.razor b/WebFrontend/Shared/PortfolioForm.razor new file mode 100644 index 0000000..99e7923 --- /dev/null +++ b/WebFrontend/Shared/PortfolioForm.razor @@ -0,0 +1,102 @@ +@using Utils +@using System.ComponentModel.DataAnnotations +@using Model + + + + +

+ + +

+ +

+ + +

+ @if (!Edit) + { + + +
+ @CurrencyUtils.GetCurrencyLabel(currencyContext) +
+
+
+ + } + + + + @if (Edit) + { + Save + } + else + { + Create + } + + + + + + + +
+ + +@code { + // model of the portfolio form + [Parameter] + public PortfolioFormModel FormModel { get; set; } = new (Currency.Usd); + + // callback called when the form is submitted + [Parameter] public EventCallback OnSubmitEventHandler { get; set; } + + // list of currencies available + [Parameter] + public List AvailableCurrencies { get; set; } + + // default currency to be used + [Parameter] + public Currency DefaultCurrency { get; set; } = Currency.Usd; + + // flag indicating whether an existing portfolio will be edited or a new one created + [Parameter] public bool Edit { get; set; } + + public class PortfolioFormModel + { + // default currency to be used + private Currency _defaultCurrency; + + public PortfolioFormModel(Currency defaultCurrency) + { + _defaultCurrency = defaultCurrency; + SelectedCurrency = defaultCurrency; + } + + [Required] + [MinLength(1)] + public string Name { get; set; } + + [Required] + [MinLength(1)] + public string Description { get; set; } + + [Required] public Currency SelectedCurrency { get; set; } + + public void Reset() + { + Name = ""; + Description = ""; + SelectedCurrency = _defaultCurrency; + } + } + + private async void OnFormSubmitted() + { + await OnSubmitEventHandler.InvokeAsync(FormModel); + FormModel.Reset(); + } +} \ No newline at end of file diff --git a/WebFrontend/Startup.cs b/WebFrontend/Startup.cs index 2010dd7..f52aa18 100644 --- a/WebFrontend/Startup.cs +++ b/WebFrontend/Startup.cs @@ -1,16 +1,19 @@ using System; using System.Net.Http; +using System.Threading.Tasks; using CryptoStatsSource; using Database; +using ElectronNET.API; +using ElectronNET.API.Entities; +using MatBlazor; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Model; using Repository; -using ServerSideBlazor.Data; +using Services; using SqlKata.Compilers; namespace WebFrontend @@ -31,22 +34,36 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddRazorPages(); services.AddServerSideBlazor(); - services.AddSingleton(); - + services.AddMatBlazor(); + + services.AddMatToaster(config => + { + config.Position = MatToastPosition.BottomCenter; + config.PreventDuplicates = true; + config.NewestOnTop = true; + config.ShowCloseButton = true; + config.MaximumOpacity = 95; + config.VisibleStateDuration = 3000; + }); - services.AddScoped(); + services.AddSingleton(); // TODO ensure that SqlKataDatabase gets disposed var dbConnection = new SqliteConnection("Data Source=data.db"); var db = new SqlKataDatabase(dbConnection, new SqliteCompiler()); - var portfolioRepository = new SqlKataPortfolioRepository(db); - portfolioRepository.Add(new Portfolio("My portfolio", "ADA holdings")); - foreach (var portfolio in portfolioRepository.All()) - { - Console.WriteLine($"{portfolio.Name} - {portfolio.Description}"); - } - dbConnection.Close(); services.AddSingleton(ctx => db); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -73,6 +90,24 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); }); + + if (HybridSupport.IsElectronActive) + { + ElectronBootstrap(); + } + } + + public async void ElectronBootstrap() + { + var url = new BrowserWindowOptions(); + url.Show = false; + url.Height = 940; + url.Width = 1152; + var browserWindow = await Electron.WindowManager.CreateWindowAsync(url); + await browserWindow.WebContents.Session.ClearCacheAsync(); + browserWindow.RemoveMenu(); + browserWindow.OnReadyToShow += () => browserWindow.Show(); + browserWindow.SetTitle("Crypto Portfolio Tracker"); } } } \ No newline at end of file diff --git a/WebFrontend/WebFrontend.csproj b/WebFrontend/WebFrontend.csproj index 8cacf10..e3ad47d 100644 --- a/WebFrontend/WebFrontend.csproj +++ b/WebFrontend/WebFrontend.csproj @@ -1,21 +1,30 @@ - - + net5.0 WebFrontend WebFrontend - + - + - - + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/WebFrontend/data.db b/WebFrontend/data.db deleted file mode 100644 index 1e19e21..0000000 Binary files a/WebFrontend/data.db and /dev/null differ diff --git a/WebFrontend/electron.manifest.json b/WebFrontend/electron.manifest.json new file mode 100644 index 0000000..879ff8e --- /dev/null +++ b/WebFrontend/electron.manifest.json @@ -0,0 +1,35 @@ +{ + "executable": "WebFrontend", + "splashscreen": { + "imageFile": "" + }, + "name": "WebFrontend", + "author": "", + "singleInstance": false, + "environment": "Production", + "build": { + "appId": "com.WebFrontend.app", + "productName": "WebFrontend", + "copyright": "Copyright © 2020", + "buildVersion": "1.0.0", + "compression": "maximum", + "directories": { + "output": "../../../bin/Desktop" + }, + "extraResources": [ + { + "from": "./bin", + "to": "bin", + "filter": [ "**/*" ] + } + ], + "files": [ + { + "from": "./ElectronHostHook/node_modules", + "to": "ElectronHostHook/node_modules", + "filter": [ "**/*" ] + }, + "**/*" + ] + } +} \ No newline at end of file diff --git a/yuml.me b/app_diagram.yuml.me similarity index 65% rename from yuml.me rename to app_diagram.yuml.me index de1ae50..020a338 100644 --- a/yuml.me +++ b/app_diagram.yuml.me @@ -1,3 +1,4 @@ + [PortfolioEntry|id: int;symbol: string;name: string;portfolio_id: int] [MarketOrder|id: int;filledPrice: decimal;fee: decimal;size: decimal,date: datetime;buy: bool;portfolio_entry_id: int] [Portfolio|id: int; name: string;description: string;currencyCode: int] @@ -17,20 +18,11 @@ [PortfolioEntryService|addEntryTo(entry;portfolio_id: int); updateEntry(entry);deleteEntry(entry);getPortfolioEntries(portfolio_id: int)] -[MarketOrderService|addOrder(order, portfolio_entry_id: int);updateOrder(order);deleteOrder(order);getAllPortfolioEntryOrders(portfolio_entry_id: int)] - -[SummaryService|getMarketEntrySummary(market_entry_id: int);getPortfolioEntrySummary(portfolio_entry_summary: int);getPortfolioSummary(portfolio_id: int)] +[MarketOrderService|addOrder(order +portfolio_entry_id: int);updateOrder(order);deleteOrder(order);getAllPortfolioEntryOrders(portfolio_entry_id: int)] - -[CryptoStatsSource|getMarketEntries(currency, ids)] +[SummaryService|getMarketOrderSummary(order, assetPrice);getPortfolioEntrySummary(orders, assetPrice);getPortfolioSummary(summaries)] [PortfolioService] -> [PortfolioRepository] [PortfolioEntryService] -> [PortfolioEntryRepository] [MarketOrderService] -> [MarketOrderRepository] - -[SummaryService] -> [MarketOrderService] -[SummaryService] -> [PortfolioEntryService] -[SummaryService] -> [CryptoStatsSource] - - - diff --git a/developer_diary.csv b/developer_diary.csv new file mode 100644 index 0000000..e99f924 --- /dev/null +++ b/developer_diary.csv @@ -0,0 +1,104 @@ +User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount () +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,Proof of concept ,No,2021-03-14,19:04:28,2021-03-14,20:19:57,01:15:29,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,Downloading crypto data,No,2021-03-15,15:25:35,2021-03-15,16:20:54,00:55:19,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,Downloading crypto data,No,2021-03-15,18:31:07,2021-03-15,18:46:04,00:14:57,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,Downloading crypto data,No,2021-03-16,15:34:30,2021-03-16,15:45:30,00:11:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,DB ,No,2021-03-18,18:41:54,2021-03-18,19:50:19,01:08:25,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,DB ,No,2021-03-18,21:07:11,2021-03-18,22:06:16,00:59:05,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,DI + DB setup,No,2021-03-18,22:30:18,2021-03-19,00:30:18,02:00:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,Repo,No,2021-04-17,07:11:33,2021-04-17,08:30:42,01:19:09,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-17,09:27:49,2021-04-17,10:02:46,00:34:57,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-17,10:27:33,2021-04-17,11:23:44,00:56:11,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-18,21:58:32,2021-04-18,23:57:21,01:58:49,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-25,22:49:57,2021-04-25,23:17:32,00:27:35,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-25,23:30:14,2021-04-25,23:38:14,00:08:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-25,23:38:18,2021-04-26,00:33:00,00:54:42,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,09:24:23,2021-04-28,09:52:12,00:27:49,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,10:00:06,2021-04-28,11:12:06,01:12:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,12:28:59,2021-04-28,12:58:59,00:30:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,13:00:54,2021-04-28,13:13:54,00:13:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,13:14:14,2021-04-28,13:56:39,00:42:25,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,14:00:34,2021-04-28,14:15:34,00:15:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,17:37:13,2021-04-28,17:58:33,00:21:20,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,18:07:35,2021-04-28,19:11:36,01:04:01,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,19:16:51,2021-04-28,19:47:51,00:31:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,20:24:41,2021-04-28,20:27:01,00:02:20,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,20:29:07,2021-04-28,21:09:24,00:40:17,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-28,22:42:14,2021-04-28,23:32:14,00:50:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-29,16:41:33,2021-04-29,16:55:50,00:14:17,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-04-29,17:33:52,2021-04-29,19:28:16,01:54:24,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-01,09:19:27,2021-05-01,09:35:49,00:16:22,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-01,09:51:22,2021-05-01,11:46:16,01:54:54,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-01,14:21:40,2021-05-01,14:55:17,00:33:37,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-01,18:33:54,2021-05-01,18:35:10,00:01:16,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-03,11:43:35,2021-05-03,12:11:13,00:27:38,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-03,14:36:30,2021-05-03,14:47:30,00:11:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-03,16:15:48,2021-05-03,16:49:41,00:33:53,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-03,16:55:59,2021-05-03,17:04:59,00:09:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-03,20:35:35,2021-05-03,22:43:35,02:08:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,06:31:51,2021-05-04,07:18:47,00:46:56,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,07:26:23,2021-05-04,08:39:30,01:13:07,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,09:05:12,2021-05-04,10:28:30,01:23:18,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,10:28:46,2021-05-04,11:19:58,00:51:12,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,13:02:39,2021-05-04,13:49:10,00:46:31,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,13:53:29,2021-05-04,14:15:29,00:22:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,14:15:30,2021-05-04,14:15:30,00:00:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,14:15:30,2021-05-04,14:36:13,00:20:43,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,14:38:21,2021-05-04,14:38:35,00:00:14,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,14:53:46,2021-05-04,14:57:54,00:04:08,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,15:07:12,2021-05-04,15:13:59,00:06:47,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,16:55:11,2021-05-04,17:12:20,00:17:09,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,19:06:15,2021-05-04,19:59:55,00:53:40,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,19:59:58,2021-05-04,20:11:08,00:11:10,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-04,21:19:07,2021-05-04,23:12:38,01:53:31,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,07:49:18,2021-05-05,11:02:10,03:12:52,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,12:42:14,2021-05-05,13:42:14,01:00:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,13:42:30,2021-05-05,13:46:53,00:04:23,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,Předvedení Vaněček,No,2021-05-05,14:07:24,2021-05-05,14:49:18,00:41:54,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,16:17:06,2021-05-05,16:18:05,00:00:59,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,16:38:57,2021-05-05,16:59:11,00:20:14,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,17:11:37,2021-05-05,17:43:50,00:32:13,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,19:44:43,2021-05-05,20:44:14,00:59:31,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,21:21:42,2021-05-05,21:33:26,00:11:44,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-05,21:38:20,2021-05-05,23:33:28,01:55:08,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-06,07:17:37,2021-05-06,08:43:23,01:25:46,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-06,08:58:26,2021-05-06,09:08:26,00:10:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,dokumentace ,No,2021-05-08,21:03:06,2021-05-08,21:03:07,00:00:01,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,dokumentace ,No,2021-05-08,21:03:15,2021-05-08,21:24:56,00:21:41,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,dokumentace ,No,2021-05-08,21:29:35,2021-05-08,23:17:20,01:47:45,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-10,07:50:48,2021-05-10,09:02:23,01:11:35,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-10,09:46:22,2021-05-10,10:06:18,00:19:56,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-10,10:09:15,2021-05-10,10:51:15,00:42:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-10,10:51:28,2021-05-10,11:30:19,00:38:51,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,SP work,No,2021-05-10,11:30:24,2021-05-10,11:54:42,00:24:18,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-19,18:31:05,2021-05-19,18:57:46,00:26:41,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-19,19:40:40,2021-05-19,19:59:10,00:18:30,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-19,20:34:02,2021-05-19,20:44:13,00:10:11,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-19,20:53:25,2021-05-19,21:56:15,01:02:50,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-19,22:06:29,2021-05-19,22:25:21,00:18:52,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-20,18:38:39,2021-05-20,18:48:08,00:09:29,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-20,18:53:30,2021-05-20,19:49:10,00:55:40,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-20,20:03:07,2021-05-20,21:25:30,01:22:23,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-20,22:09:07,2021-05-20,23:15:53,01:06:46,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-20,23:24:52,2021-05-20,23:27:54,00:03:02,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-21,07:21:41,2021-05-21,08:06:24,00:44:43,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-21,11:24:41,2021-05-21,13:22:13,01:57:32,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-21,13:59:41,2021-05-21,14:00:18,00:00:37,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,08:06:40,2021-05-22,08:23:32,00:16:52,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,08:42:55,2021-05-22,09:02:46,00:19:51,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,09:10:52,2021-05-22,09:24:52,00:14:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,09:44:12,2021-05-22,10:00:12,00:16:00,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,10:36:53,2021-05-22,10:56:48,00:19:54,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,13:05:43,2021-05-22,13:22:55,00:17:12,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-22,15:53:29,2021-05-22,16:38:16,00:44:47,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,07:15:00,2021-05-23,07:17:38,00:02:38,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,07:45:12,2021-05-23,09:33:01,01:47:49,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,10:32:56,2021-05-23,11:20:57,00:48:01,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,11:26:17,2021-05-23,12:11:07,00:44:50,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,13:31:29,2021-05-23,14:26:52,00:55:23,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,14:26:57,2021-05-23,14:39:33,00:12:36,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,15:33:39,2021-05-23,17:26:18,01:52:39,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,19:17:43,2021-05-23,19:57:22,00:39:39,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,20:04:55,2021-05-23,21:03:33,00:58:38,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,sp work,No,2021-05-23,21:03:38,2021-05-23,21:32:36,00:28:58,SP, +Stanislav Kral3,stanislav.kral3@gmail.com,,NET,,doku,No,2021-05-23,21:39:04,2021-05-23,22:41:49,01:02:45,SP, diff --git a/doc/doc.tex b/doc/doc.tex index 8658d4f..b6e381a 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -1,3 +1,4 @@ +%! suppress = Unicode \documentclass[12pt, a4paper]{article} \usepackage[czech,shorthands=off]{babel} @@ -14,6 +15,7 @@ \usepackage{tabularx} \usepackage[final]{pdfpages} \usepackage{syntax} +\usepackage{listings} \definecolor{mauve}{rgb}{0.58,0,0.82} @@ -29,6 +31,8 @@ \usepackage[sorting=nyt,style=ieee]{biblatex} \addbibresource{literatura.bib} +\lstdefinestyle{sharpc}{language=[Sharp]C, frame=lr, rulecolor=\color{blue!80!black}} + \lstdefinestyle{flex}{ frame=tb, aboveskip=3mm, @@ -45,7 +49,7 @@ breakatwhitespace=true, tabsize=3 } - +\lstset{style=sharpc} \lstset{ frame=tb, language=XML, @@ -65,22 +69,21 @@ } -\let\oldsection\section -\renewcommand\section{\clearpage\oldsection} +\let\oldsection\section\renewcommand\section{\clearpage\oldsection} \begin{document} - % this has to be placed here, after document has been created - % \counterwithout{lstlisting}{chapter} - \renewcommand{\lstlistingname}{Ukázka kódu} - \renewcommand{\lstlistlistingname}{Seznam ukázek kódu} + % this has to be placed here, after document has been created + % \counterwithout{lstlisting}{chapter} + \renewcommand{\lstlistingname}{Ukázka kódu} + \renewcommand{\lstlistlistingname}{Seznam ukázek kódu} \begin{titlepage} \centering \vspace*{\baselineskip} \begin{figure}[H] - \centering - \includegraphics[width=7cm]{img/fav-logo.jpg} + \centering + \includegraphics[width=7cm]{img/fav-logo.jpg} \end{figure} \vspace*{1\baselineskip} @@ -112,64 +115,428 @@ \tableofcontents \pagebreak -\section{Zadání práce} -Zadáním práce je vytvořit aplikaci určenou ke sledování obchodního portfólia s kryptoměnami pomocí frameworku .NET 5.0, kdy aplikace bude nabízet grafické rozhraní pro její ovládání. Aplikace musí splňovat následující body: -\begin{itemize} - \item možnost vytvořit portfólia, do kterých budou členěněny jednotlivé transakce (jedno portfólio může obsahovat pouze transakce v jedné fiat měně) - \item jednotlivé transakce budou obsahovat informaci o nákupní či prodejní ceně za jednu minci kryptoměny, počet mincí, kterých se obchod týkal, datum uskutečnění obchodu a poplatek za jeho zprostředkování - \item možnost zobrazit si celkový zisk či ztrátu portfólia - \item možnost zobrazit si zisk či ztrátu na úrovni jednotlivých transakcí - \item možnost zobrazit si procentuální složení daného portfólia - \item možnost získat aktuální kurz vybrané kryptoměny ze zdroje dostupného přes veřejně dostupné REST API - \item vhodně navržená architektura umožující možnost jednoduchou výměnu datové vrstvy či zdroje aktuálního kurzu -\end{itemize} + \section{Zadání práce} + Zadáním práce je vytvořit aplikaci určenou ke sledování obchodního portfólia s kryptoměnami pomocí frameworku .NET 5.0, kdy aplikace bude nabízet grafické rozhraní pro její ovládání. Aplikace musí splňovat následující body: + + \begin{itemize} + \item možnost vytvořit portfólia, do kterých budou členěněny jednotlivé transakce (jedno portfólio může obsahovat pouze transakce v jedné fiat měně) + \item jednotlivé transakce budou obsahovat informaci o nákupní či prodejní ceně za jednu minci kryptoměny, počet mincí, kterých se obchod týkal, datum uskutečnění obchodu a poplatek za jeho zprostředkování + \item možnost zobrazit si celkový zisk či ztrátu portfólia + \item možnost zobrazit si zisk či ztrátu na úrovni jednotlivých transakcí + \item možnost zobrazit si procentuální složení daného portfólia + \item možnost získat aktuální kurz vybrané kryptoměny ze zdroje dostupného přes veřejně dostupné REST API + \item vhodně navržená architektura umožující možnost jednoduchou výměnu datové vrstvy či zdroje aktuálního kurzu + \end{itemize} - -\section{Sledování obchodního portfólia s kryptoměnami} -Obchodování kryptoměň spočívá v nákupu a prodeji kryptoměn na burzách za účelem zisku, kdy obchodník chce prodat kryptoměny za vyšší částku než je nakoupil. Je typické, že takový obchodník provádí velké množství obchodů (až několik týdně) a i přesto, že burzy s kryptoměnami sice poskytují přehled historie uskutečněných transakcí, tak tento přehled nebývá často dostatečně obsáhlý a některé informace, jako například zisk či ztráta, se v něm nezobrazují. Dále je také časté, že obchodníci používají k obchodování více než jednu burzu, a tak vzniká potřeba nějaké služby či aplikace, ve které by byly transakce ze všech burz uložené, a která by nabízela jednoduché a společné rozhraní pro všechny burzy. Od takové služby je vyžadovaný i kvalitní přehled zobrazující aktuální výkon obchodování a celkovou hodnotu výdělku či ztráty. Mezi nejpopulárnější kryptoměnové burzy patří například \textit{Coinbase} či \textit{Binance}. + \section{Sledování obchodního portfólia s kryptoměnami} -Za předpokladu existence takové aplikace by jejím dalším využitím, jelikož sdružuje transakce ze všech burz, bylo použití při vyplňování daňového přiznání, kdy je velmi výhodné, že aplikace zobrazuje všechny obchodníkovy transakce včetně zisku či ztráty. + Obchodování kryptoměň spočívá v nákupu a prodeji kryptoměn na burzách za účelem zisku, kdy obchodník chce prodat kryptoměny za vyšší částku než je nakoupil. Je typické, že takový obchodník provádí velké množství obchodů (až několik týdně) a i přesto, že burzy s kryptoměnami sice poskytují přehled historie uskutečněných transakcí, tak tento přehled nebývá často dostatečně obsáhlý a některé informace, jako například zisk či ztráta, se v něm nezobrazují. Dále je také časté, že obchodníci používají k obchodování více než jednu burzu, a tak vzniká potřeba nějaké služby či aplikace, ve které by byly transakce ze všech burz uložené, a která by nabízela jednoduché a společné rozhraní pro všechny burzy. Od takové služby je vyžadovaný i kvalitní přehled zobrazující aktuální výkon obchodování a celkovou hodnotu výdělku či ztráty. Mezi nejpopulárnější kryptoměnové burzy patří například \textit{Coinbase} či \textit{Binance}. -Aplikací, které se zaměřují na sledování obchodního portfólia s kryptoměnami existuje několik, kdy mezi ty nejpoužívanější patří Blockfolio\cite{blockfolio2021} (Android a iOS), Delta\cite{delta2021} (Android a iOS) a Moonitor\cite{moonitor2021} (macOS, Windows a Linux). Tyto aplikace splňují požadavek přehledného zobrazení výnosnosti obchodování i na úrovni jednotlivých transakcí, avšak během jejich používání můžou často vzniknout nové požadavky specifické danému uživateli, jako například možnost importu transakcí z API nějaké méně známé burzy či jiný výpočet celkového zisku portfólia, které do aplikace pravděpodobně nikdy nebudou zapracovány. Řešením pro technicky zdatné uživatele by bylo si takovou aplikaci navrhnout a napragramovat, avšak vytváření architektury a základní logiky pro správu a sledování portfólia (zádávní transakcí a jejich přehled) pro ně může být časově náročné a tudíž odrazující. + Za předpokladu existence takové aplikace by jejím dalším využitím, jelikož sdružuje transakce ze všech burz, bylo použití při vyplňování daňového přiznání, kdy je velmi výhodné, že aplikace zobrazuje všechny obchodníkovy transakce včetně zisku či ztráty. -\begin{figure}[!ht] -\centering -{\includegraphics[width=5.5cm]{img/blockfolio.png}} -\caption{Mobilní aplikace Blockfolio umožňující sledovat kryptoměnové portfólio} -\label{fig:simple-vrp-czech} -\end{figure} + Aplikací, které se zaměřují na sledování obchodního portfólia s kryptoměnami existuje několik, kdy mezi ty nejpoužívanější patří Blockfolio\cite{blockfolio2021} (Android a iOS), Delta\cite{delta2021} (Android a iOS) a Moonitor\cite{moonitor2021} (macOS, Windows a Linux). Tyto aplikace splňují požadavek přehledného zobrazení výnosnosti obchodování i na úrovni jednotlivých transakcí, avšak během jejich používání můžou často vzniknout nové požadavky specifické danému uživateli, jako například možnost importu transakcí z API nějaké méně známé burzy či jiný výpočet celkového zisku portfólia, které do aplikace pravděpodobně nikdy nebudou zapracovány. Řešením pro technicky zdatné uživatele by bylo si takovou aplikaci navrhnout a naprogramovat, avšak vytváření architektury a základní logiky pro správu a sledování portfólia (zadávání transakcí a jejich přehled) pro ně může být časově náročné a tudíž odrazující. -\section{Datový zdroj s aktuálním kurzem kryptoměn} + \begin{figure}[!ht] + \centering + {\includegraphics[width=5.5cm]{img/blockfolio.png}} + \caption{Mobilní aplikace Blockfolio umožňující sledovat kryptoměnové portfólio} + \label{fig:simple-vrp-czech} + \end{figure} -Pro vývoj aplikace určené ke sledování obchodního portfólia s kryptoměnami je třeba nalézt vhodný zdroj dat, který bude využíván k získávání aktuálního kurzu sledovaných kryptoměn. Mezi hlavní požadavky na takový datový zdroj je jeho dostupnostie jednoduchost rozhraní a množina podporovaných kurzů. Ideálním zdrojem je tedy takový zdroj, který poskytuje aktuální i historický kurz na všech burzách prostřednictvím REST API bez nutnosti registrace. -\subsection{Webový zdroj CoinGecko} -Aktuální i historický kurz drtivé většiny všech existujících kryptoměn bez nutnosti registrace nabízí pomocí REST rozhraní webová služba CoinGecko\cite{coingecko2021}. Jediným omezením tohoto API je počet provedených požadavků za minutu, který je stanoven na 100, což je pro aplikaci určenou ke sledování kryptoměnového portfólia více než dostačující. + \section{Analýza} + + \subsection{Datový zdroj s aktuálním kurzem kryptoměn} -\begin{lstlisting} -$ curl -X GET "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_cur -rencies=usd" -H "accept: application/json" + Pro vývoj aplikace určené ke sledování obchodního portfólia s kryptoměnami je třeba nalézt vhodný zdroj dat, který bude využíván k získávání aktuálního kurzu sledovaných kryptoměn. Mezi hlavní požadavky na takový datový zdroj je jeho dostupnost, jednoduchost rozhraní a množina podporovaných kurzů. Ideálním zdrojem je tedy takový zdroj, který poskytuje aktuální i historický kurz na všech burzách prostřednictvím REST API bez nutnosti registrace. + + \subsubsection{Webový zdroj CoinGecko} + Aktuální i historický kurz drtivé většiny všech existujících kryptoměn bez nutnosti registrace nabízí pomocí REST rozhraní webová služba CoinGecko\cite{coingecko2021}. Jediným omezením tohoto API je počet provedených požadavků za minutu, který je stanoven na 100, což je pro aplikaci určenou ke sledování kryptoměnového portfólia více než dostačující. + + \begin{lstlisting} +\$ curl -X GET "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" -H "accept: application/json" { "bitcoin": { "usd": 56224 } } -\end{lstlisting} + \end{lstlisting} + + \subsection{Výběr databáze pro implementaci datové vrstvy aplikace} + + Jelikož vytvářená aplikace není určená pro použití vícero uživateli najednou, ale pouze pro jednoho uživatele na jednom zařízení, tak pro ukládání dat aplikace je vhodná lokální databáze. + + V úvahu připadá ukládat portfólia a transakce ve formátu JSON či XML přímo na souborový systém, ale z důvodu relace M:N mezi portfólii a kryptoměnami nejsou tyto typy databází příliš vhodné. Jako lepší volba tedy jeví nějaká relační databáze, např. SQLite, která je často používána při tvorbě desktopových aplikací a ukládá se ve formě jednoho souboru na souborový systém zařízení. + + \subsection{Výběr frameworku pro implementaci GUI} + Jedním s cílů této semestrální práce bylo, aby vytvořená aplikace byla spustitelná jak na platformě Windows, tak i na platformě Linux. + Ačkoliv platforma .NET je multiplatformní, tak při vývoji aplikací s grafickým uživatelským rozhraním narážíme na problém, + kdy tato platforma nenabízí nástroje pro jeho tvorbu s použitím na různých platformách. + + \subsubsection{.NET MAUI} + Zkratka \textbf{MAUI} je označení pro připravovaný framework pro tvorbu multiplatformních aplikací využívající + grafické uživatelské rozhraní a znamená .NET Multi-platform App UI\footnote{\url{https://devblogs.microsoft.com/xamarin/the-new-net-multi-platform-app-ui-maui/}}. + Tento framework je vyvíjen jako open-source software a na jeho vývoji se podílí hlavně Microsoft, který slibuje podporu + platforem Android, iOS, a UWP. Zatím je však pouze ve vývoji a bude vydán společně s .NET 6. + + \subsubsection{Avalonia UI} + Dalším z multiplatformní frameworků dostupných pro platformu .NET je framework Avalonia UI\footnote{\url{https://avaloniaui.net/}} + umožňující vytvářet desktopové aplikace pro Windows, macOS a Linux, kdy k definici uživatelského rozhraní používá formát + XAML. Aktuální verze tohoto frameworku je v0.10.5 a tudíž se stále ještě nachází v aktivním vývoji, kdy se mohou v jeho API objevovat + významné změny. + + \subsubsection{Blazor} + Framework Blazor\footnote{\url{https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor}} umožňuje vývojářům vytvářet + interaktivní webové aplikace postavené na platformě .NET v jazyce HTML s využitím CSS a je součástí frameworku + ASP.NET\footnote{\url{https://dotnet.microsoft.com/apps/aspnet}}. + Jeho použití je pak možné jak na platformě Windows, tak i macOS a Linux. + + Skutečnost, že Blazor umožňuje vytvářet webové aplikace postavené na platformě .NET, lze v kombinaci s platformou Electron\footnote{\url{https://www.electronjs.org/}} + využít ke tvorbě multiplatformních desktopových aplikací, kdy je vývojáři k tomuto účelu hojně využíván\footnote{\url{https://www.electronjs.org/apps}}, + a podporuje platformy Windows, macOS i Linux. + + \section{Popis architektury vytvořené aplikace} -\section{Výběr databáze pro implementaci datové vrstvy aplikace} + \begin{figure}[!ht] + \centering + {\includegraphics[width=1.15\textwidth]{img/app-diagram.pdf}} + \caption{Zjednodušený diagram vytvořené aplikace} + \label{fig:app-diagram} + \end{figure} + + \subsection{Databázová vrstva} + Ve vytvořené aplikaci je datová vrstva implementována pomocí tzv. \textit{repozitářů}, kdy každý repozitář představuje perzistentní úložiště dané entity, do kterého lze zapisovat a následně z něj číst. + + Kontrakt generického rozhraní repozitáře se skládá z následujících definic metod: + \begin{itemize} + \item \texttt{public int Add(T entry)} -- přidá daný objekt do perzistentního úložiště a vrátí vygenerované ID + \item \texttt{public T Get(int id)} -- vyhledá a případně vrátí objekt z perzistentního úložiště dle předaného identifikátoru \texttt{id} + \item \texttt{List GetAll()} -- vyhledá a případně vrátí seznam všech objektů z perzistentního úložiště + \item \texttt{public bool Update(T entry)} -- nahraje do perzistentního úložiště aktualizovanou verzi objektu, který se zde již nachází. Vrátí \texttt{true}, pokud aktualizace proběhla úspěšně nebo \texttt{false}, pokud během této operace došlo k nějaké chybě. + \item \texttt{public bool Delete(T entry)} -- smaže předaný objekt z úložiště a vrátí \texttt{true}, pokud smazání proběhlo úspěšně nebo \texttt{false}, pokud během této operace došlo k nějaké chybě. + + \end{itemize} + + \noindent Ve vytvořené aplikaci jsou definovány následující repozitáře: + \begin{itemize} + \item \texttt{IPortfolioRepository} -- úložiště objektů představujících jednotlivá portfólia spravované v aplikaci + \item \texttt{IPortfolioEntryRepository} -- úložiště objektů představujících položky existujících portfólií + \item \texttt{IMarketOrderRepository} -- úložiště objektů představujících uskutečněné obchody dané položky portfólia + \end{itemize} + + \subsubsection{Generování SQL dotazů} + K jednoduchému a intuitivnímu generování SQL dotazů je použita knihovna \textbf{SqlKata Query Builder}\footnote{\url{https://github.com/sqlkata/querybuilder}}, kdy lze SQL dotazy vytvářet pomocí řetězení volání metod poskytovaných touto knihovnou. + + \begin{lstlisting}[language=Java, caption={Příklad generování SQL dotazu pro výběr všech transakcí dané položky portfólia pomocí knihovny SqlKata Query Builder.},captionpos=b, label={lst:sm-showcase}] +Db.Get().Query("orders").Where("portfolio_entry_id", portfolioEntryId).Get() + \end{lstlisting} + + Tato knihovna přímo umožňuje nad předaným databázovým spojením vygenerovaný SQL dotaz přímo vykonat, kdy k této činnosti využívá knihovnu \textbf{Dapper}\footnote{\url{https://github.com/DapperLib/Dapper}}. + Výsledkem metod bez specifikování typu pro vykonání generovaných dotazů jsou objekty typu \texttt{dynamic}, které je třeba mapovat na instance tříd dle modelu entity se kterou pracujeme. + + \begin{lstlisting}[language=Java,caption={Příklad mapování objektu typu \texttt{dynamic} na instanci třídy \texttt{Portfolio}.},captionpos=b, label={lst:sm-mapping}] +public override Portfolio FromRow(dynamic d) => + new Portfolio((string) d.name, (string) d.description, (Currency) d.currency_code, (int) d.id); + \end{lstlisting} + + Jelikož velká část kódu pro implementaci metod repozitáře je pro všechny možné typy stejná, tak je tato část kódu sdílena pomocí abstraktní třídy \texttt{SqlKataRepository}. Implementace pro konkrétní třídy modelu musí akorát implementovat kód pro vytvoření instance dané třídy z objektu typu \texttt{dynamic} a naopak. V aplikaci se nachází implementace \texttt{SqlKataPortfolioRepository}, \texttt{SqlKataPortfolioEntryRepository} a \texttt{SqlKataMarketOrderRepository}. + + \subsection{Služba \texttt{IPortfolioService}} + Tato služba poskytuje rozhraní pro správu portfólií v perzistentním úložišti. + Umožňuje vytvořit a přidat nové portfólio do repozitáře na základě jeho atributů předaných pomocí parametrů. + Dále také poskytuje metodu pro smazání portfólia, která smaže i všechny jeho položky. + + \subsection{Služba \texttt{IPortfolioEntryService}} + Velmi podobně jako služba \texttt{IPortfolioService}, tato služba poskytuje rozhraní pro správu položek portfólií v perzistentním úložišti. + Kromě vytvoření a přidání nových položek na základě parametrů také nabízí metodu pro její smazání, která smaže i všechny transakce, které k ní byly přiřazeny. + Dále disponuje metodou pro výběr všech položek, které patří do vybraného portfólia. + + \subsection{Služba \texttt{IMarketOrderService}} + Rozhraní pro správu transakcí v perzistentním úložišti poskytuje právě tato služba, kdy poskytuje i metodu pro vyhledání všech transakcí patřících do vybrané položky portfólia. + + \subsection{Napojení na online datový zdroj kurzů kryptoměn} + Aby aplikace mohla zobrazovat nejaktuálnější výnosnost investicí, tak potřebuje být schopna získávat data z datového zdroje kurzů kryptoměn. + Pro tyto účely slouží obecné rozhraní \texttt{ICryptoStatsSource}, které definuje následující metody: + + \begin{itemize} + \item \texttt{GetMarketEntries(string currency, params string[] ids)} -- stáhne nejaktuálnější informace o vybraných kryptoměnách, kdy údaje o cenách jsou ve měně vybrané pomocí parametru \texttt{currency}. + Mezi stahované informace patří například zkratka kryptoměny, její název, aktuální hodnota či tržní kapitalizace. + \item \texttt{GetAvailableCryptocurrencies()} -- stáhne seznam kryptoměn, na které se může volající datového zdroje tázat. + V seznamu dostupných kryptoměn je u každé informace o jejím názvu, zkratce a identifikátoru. + \end{itemize} + + Ve vytvořené aplikaci se nachází implementace \texttt{CoingeckoSource}, která k získávání informací o kryptoměnách používá REST API rozhraní služby Coingecko. + K vytváření HTTP požadavků, pomocí kterých komunikuje se zmíněnou službou, tato implementace používá knihovnu \textbf{TinyRestClient}\footnote{\url{https://github.com/jgiacomini/Tiny.RestClient}}. + + \subsection{Služba pro výpočet výkonu jednotlivých entit} + Aby bylo možné vypočítat výkon (zisk či ztráta) jednotlivých entit (portfólio, položka portfólia či uskutečněný obchod), tak bylo vytvořeno rozhraní \texttt{ISummaryService} a jeho implementace \texttt{SummaryServiceImpl}. + + \subsubsection{Výpočet výkonu transakce} + Výpočet výkonu jednotlivých transakcí je inspirován výpočtem výkonu v aplikaci Blockfolio, kdy se nebere v potaz informace, zdali daná transakce byla nákup či prodej. + Před výpočtem je nastavena aktuální cena za jednu minci kryptoměny, pomocí které se vypočítává, zdali je transakce v zisku či ztrátě. + + Nejdřív se vypočte aktuální hodnota transakce tak, že se vynásobí cena za jednu minci jejím objemem. + Od aktuální hodnoty transakce se odečte její hodnota při jejím vytvoření (investovaná částka), čímž získáme informaci, jestli je v zisku či ztrátě. + Porovnáním poměru aktuálním hodnoty vůči hodnotě při vytvoření transakce získáme její relativní změnu. + + \subsubsection{Výpočet výkonu položky portfólia} + Při výpočtu výkonu položky portfólia se iteruje nad jejími transakcemi a sčítá se celkový obchodovaný objem. + U transakcí, které představují prodej, se z celkové sumy obchodovaného objemu odečítá. + Nakonec se celkový obchodovaný objem vynásobí aktuální cenou komodity, čímž se získá aktuální tržní hodnota dané položky portfólia. + + Celková změna hodnoty položky je pak vypočtena jako součet celkové tržní hodnoty a hodnoty prodejů, odečtena od celkové investice a sumy poplatků za transakce. + Relativní změna představuje poměr mezi tržní hodnotou a celkové investice, ke které je připočtena suma poplatků. + + Výpočet výkonu položky portfólia je inspirován výpočtem v aplikaci Blockfolio. + + \subsubsection{Výpočet výkonu portfólia} + Výpočet celkového výkonu portfólia se vypočte tak, že jsou zprůměrovány výkony všech jeho položek. + + \subsection{Projekt \texttt{Utils}} + V tomto projektu se nachází pomocné třídy definující metody, které usnadňují práci s některými datovými typy. + Mezi takové třídy patří: + + \begin{itemize} + \item \texttt{CurrencyUtils} -- statická třída definující metody k získávání zkratky měny či formátování částky v dané měně + \item \texttt{DecimalUtils} -- statická třída definující metody k formátování číselných hodnot + \item \texttt{EnumUtils} -- statická třída definující metodu k získání všech možných hodnot libovolného výčtového typu + \end{itemize} + + \subsection{Projekt \texttt{WebFrontend}} + Definice grafického uživatelského rozhraní spolu s jeho logikou se nachází v projektu \texttt{WebFrontend}, kdy byl + použit framework Blazor. + Jedná se o spustitelný projekt, který nastartuje celou aplikaci. + + V souboru \texttt{Startup.cs} se nachází konfigurace a inicializace aplikace, kdy je nutné vytvořit strom závislostí, + které pak jednotlivé obrazovky využívají. + Konfiguruje se zde i například připojení k SQLite databázi. + + Tento projekt používá knihovnu Electron.NET\footnote{\url{https://github.com/ElectronNET/Electron.NET}} k tomu, aby ji bylo možné spustit v Electron kontejneru. + + \subsubsection{Adresář \texttt{Pages}} + V adresáři \texttt{Pages} jsou umístěny soubory s příponou \texttt{.razor}, které využívají syntax kombinující HTML + a C\# k definici následujících interaktivních webových stránek: + + \begin{itemize} + \item \texttt{EditMarketOrder.razor} -- definice obrazovky pro úpravu existující transakce + \item \texttt{EditPortfolio.razor} -- definice obrazovky pro úpravu existujícího portfólia + \item \texttt{Index.razor} -- definice úvodní obrazovky zobrazující seznam existujících portfólií + \item \texttt{NewMarketOrder.razor} -- definice obrazovky k vytvoření nové transakce + \item \texttt{PortfolioDetail.razor} -- definice obrazovky zobrazující informace o vybraném portfóliu + \item \texttt{PortfolioEntryDetail.razor} -- definice obrazovky zobrazující informace o vybrané položce portfólia + \item \texttt{PortfolioEntryManagement.razor} -- definice obrazovky ke správě položek portfólia + \end{itemize} + + Samotná implementace těchto obrazovek implementuje pouze minimální aplikační logiku, jelikož hojně využívá rozhraní z projektu + \texttt{Services}, která ji jsou dostupná pomocí stromu závislostí s využitím anotace \texttt{@inject}. + + \subsubsection{Adresář \texttt{Shared}} + Znovupoužitelné komponenty grafického rozhraní se nachází ve složce \texttt{Shared} a používají se v následujících definicích obrazovek: + + \begin{itemize} + \item \texttt{LabelDecimalValue.razor} -- definice komponenty, která umožňuje zobrazit libovolné číslo a přiřadit + k němu nějaký popisek. V aplikaci se využívá k zobrazování vlastností a výkonnosti jednotlivých sledovaných entit + (portfólia, položky portfóli či transakce). + \item \texttt{OrderForm.razor} -- definice formuláře, který se používá na stránkách pro vytvoření a úpravu transakce + \item \texttt{MainLayout.razor} -- definice obsahující základní HTML prvky, které obalují obsah jednotlivých stránek implementující obrazovky + \item \texttt{PortfolioForm.razor} -- definice formuláře, který se používá na stránkách pro vytvoření a úpravu portfólia + \end{itemize} + + \section{Ověření kvality vytvořeného software} + + Pro ověření kvality vytvořeného software pro sledování obchodního portfólia s kryptoměnami byly vytvořeny desítky jednotkových a integračních testů ověřující funkčnost základních modulů. Tyto testy se nachází v projektu \texttt{Tests}. Jako testovací framework byl zvolen framework \texttt{XUnit}\footnote{\url{https://github.com/xunit/xunit}}. + + Knihovna \texttt{moq}\footnote{\url{https://github.com/moq/moq4}} byla použita pro vytvoření \textbf{mock} objektů datové vrstvy při testování kódu služeb \texttt{PortfolioService}, \texttt{PortfolioEntryService} a \texttt{MarketOrderService} (jednotkové testy). + + Během integračních testů repozitářů datové vrstvy není použita databáze umístěná na souborovém systému, nýbrž databáze uložená v operační paměti z důvodu urychlení vykonávání testů. Připojení k databázi umístěné v operační paměti slouží řetězec \texttt{Data Source=:memory:}. + + Vytvořené integrační testy ověřují funkčnost datové vrstvy a datového zdroje pro získávání informací o aktuálním stavu trhu s kryptoměnami. + + \begin{lstlisting}[caption={Struktura projektu \texttt{Tests} obsahující integrační a jednotkové testy}, captionpos=b] + |-- Integration + | |-- CryptoStatsSource + | | |-- CryptoNameResolverTest.cs + | | `-- CryptoStatsSourceTest.cs + | `-- Repository + | |-- MarketOrderTest.cs + | |-- PortfolioEntryTest.cs + | `-- PortfolioTest.cs + `-- Unit + `-- Service + |-- MarketOrderServiceTest.cs + |-- PortfolioEntryServiceTest.cs + |-- PortfolioServiceTest.cs + `-- SummaryServiceTest.cs + \end{lstlisting} + + \section{Závěr} + V rámci této semestrální práce byla navržena a implementovaná multiplatformní desktopová aplikace využívající frameworky + Blazor a Electron k vytvoření grafického uživatelského rozhraní, která splňuje všechny požadované body zadání. + Uživateli umožňuje zadávat uskutečněné transakce a třídit je do jednotlivých portfólií, které jsou ukládány do perzistentního + datového úložiště na souborovém systému s využitím databázové technologie SQLite. + + Navržená a implementovaná architektura od sebe odděluje logické části aplikace, a tak například dovoluje jednoduše změnit zdroj kurzů kryptoměn či implementaci datové vrstvy, + čímž aplikaci činí robustní a lehce rozšiřitelnou. + + Vytvořená aplikace díky připojení na online datový zdroj kurzů kryptoměn služby CoinGecko umožňuje vypočítat výnosnost + zadaných investic, což splňuje hlavní požadavky investorů a obchodníků s kryptoměnami na takovou aplikaci. + + Ověření kvality vytvořeného software je implementováno jak na úrovni jednotkových testů, tak i na úrovni integračních testů, + kdy se ověřuje správný zápis do databáze či napojení na online datový zdroj. Pokrytí projektů \texttt{Services}, + \texttt{Repository} a \texttt{CryptoStatsSource} dosahuje hodnoty 100 procent. + + Jako možné rozšíření aplikace lze považovat vylepšení datové vrstvy tak, aby nepoužívala objekty typu \texttt{dynamic}, + a více tak využívala funkcionalitu knihovny Dapper. + Dalším rozšířením by mohlo být pokrytí uživatelského rozhraní automatickými testy, například pomocí nástroje Robot + framework. + + Navzdory velkému vynaloženému úsilí se nepovedlo sestavit pomocí knihovny Electron.NET soubor se spustitelnou verzí aplikaci + pro nejrozšířenější desktopové platformy. Pravděpodobně se jedná o chybu dané knihovny\footnote{\url{https://github.com/ElectronNET/Electron.NET/issues/398}}. + Spuštění Electron kontejneru je však možné pomocí příkazu + \texttt{electronize start /PublishSingleFile false /PublishReadyToRun false --no-self-contained} vykonaného v + kořeni projektu \texttt{WebFrontend}. + + Vývoj multiplatformní desktopové aplikace pomocí kombinace frameworků Blazor a Electron s sebou přináší spoustu + komplikací, a nelze ji tak v současné době považovat za vhodnou volbu pro většinu vývojářů. + Dá se však očekávat, že v blízké budoucnosti se podpora Blazor aplikací obalených kontejnerem Electron zlepší, + a bude se tak jednat o dobrou platformu pro jejich tvorbu. + + \section{Programátorský deník} + K vytvoření programátorského deníku byl používán software Toggl\footnote{\url{https://toggl.com/}}, kdy byl podle něj + naměřen celkový čas strávený na této semestrální práci 77 hodin: + + \begin{itemize} + \item \textbf{3h} -- úvodní seznámení s frameworkem Blazor, výběr a napojení na datový zdroj CoinGecko + \item \textbf{10h} -- implementace datové vrstvy a její pokrytí testy + \item \textbf{9h} -- implementace projektu \texttt{Services} a její pokrytí testy + \item \textbf{11h} -- návrh a implementace GUI + \item \textbf{5h} -- implementace volání služeb z GUI + \item \textbf{7.5h} -- použití frameworku Electron + \item \textbf{8h} -- implementace služby k výpočtu výkonosti entit a její pokrytí testy + \item \textbf{4h} -- doladění aplikace, vylepšení pokrytí testy + \item \textbf{20h} -- psaní dokumentace + \end{itemize} + + \section{Uživatelská příručka} + + \subsection{Úvodní obrazovka} + Na úvodní obrazovce aplikace se nachází seznam všech vytvořených portfólií, kdy po kliknutí na jeho položku se otevře detail vybraného portfólia. + U každého portfólia se nachází i tlačítka k otevření formuláře pro jeho úpravu či smazání. + Nachází se zde také tlačítko, které po stisknutí otevře obrazovku k vytváření nových portfólií. -Jelikož vytvářená aplikace není určená pro použití vícero uživateli najednou, ale pouze pro jednoho uživatele na jednom zařízení, tak pro ukládání dat aplikace je vhodná lokální databáze. + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{img/cpt-screenshots/portfolio-list.png}} + \caption{Úvodní obrazovka zobrazující seznam všech portfólií} + \label{fig:portfolio-list} + \end{figure} + + \subsection{Detail portfólia} + K zobrazení výkonu vybraného portfólia a všech položek, které se v něm nachází, je určena právě tato stránka, + na které se v její horní části nachází celková hodnota portfólia a jeho procentuální výnosnost. + V tabulce, která je umístěna uprostřed obrazovky, se nachází seznam všech položek portfólia obsahující informaci o + aktuální ceně sledovaných kryptoměn a jejich podílu v zobrazovaném portfóliu. + Po kliknutí na položku v tabulce se otevře detail vybrané položky portfólia. + + \begin{figure}[!ht] + \centering + {\includegraphics[width=0.7\textwidth]{img/cpt-screenshots/portfolio-detail.png}} + \caption{Obrazovka zobrazující detail portfólia} + \label{fig:portfolio-detail} + \end{figure} + + V dolní části obrazovky se nachází tlačítko pro správu položek portfólia. + + \subsection{Formulář pro vytváření/editaci portfólií} + K vytváření či editaci portfólií slouží jednoduchý formulář, který obsahuje povinná vstupní pole pro zadání názvu portfólia a jeho popisu. + Dále obsahuje tlačítka pro výběr v jaké měně budou prováděny transakce. + Nakonec se ve spodní části nachází tlačítka pro odeslání formuláře či jeho obnovení. -V úvahu připadá ukládat portfólia a transakce ve formátu JSON či XML přímo na souborový systém, ale z důvodu relace M:N mezi portfólii a kryptoměnami nejsou tyto typy databází příliš vhodné. Jako lepší volba tedy jeví nějaká relační databáze, např. SQLite, která je často používána při tvorbě desktopových aplikací a ukládá se ve formě jednoho souboru na souborový systém zařízení. + \begin{figure}[!ht] + \centering + {\includegraphics[width=0.7\textwidth]{img/cpt-screenshots/portfolio-form.png}} + \caption{Formulář k vytvoření či editaci portfólia} + \label{fig:portfolio-form} + \end{figure} + + \subsection{Detail položky portfólia} + Detail položky portfólia obsahuje v horní části stránky detailní výpis její výnosnosti, který se skládá z následujících údajů: + \begin{itemize} + \item \textit{Market value} -- aktuální hodnota položky portfólia + \item \textit{Holdings} -- aktuálně vlastněný objem kryptoměny, které se vybraná položka týká + \item \textit{Profit/Loss} -- zisk či ztráta ve měně portfólia, do kterého položka patří + \item \textit{Net cost} -- celková investovaná částka do kryptoměny vybrané položky + \item \textit{Avg net cost} -- průměrná nákupní cena dané položky + \item \textit{Percent change} -- procentuální zisk či ztráta vybrané položky + \end{itemize} + + Pod výpisem výnosnosti se nachází tabulka všech uskutečněných transakcí v rámci této položky, která obsahuje následující sloupce: + \begin{itemize} + \item \textit{Date} -- datum, kdy byla transakce uskutečněna + \item \textit{Size} -- počet obchodovaných mincí dané kryptoměny + \item \textit{Price} -- cena, za kterou byla obchodována jedna mince kryptoměny + \item \textit{Market value} -- aktuální hodnota obchodovaného množství kryptoměny (vzhledem k jejímu aktuálnímu kurzu) + \item \textit{Change} -- výnosnost či zisk vybrané transakce v absolutní a relativní hodnotě + \item \textit{Actions} -- tlačítka pro smazání či úpravu transakce + \end{itemize} + + Ve spodní části obrazovky je umístěné tlačítko pro přidání nové transakce. -\section{Framework pro grafické rozhraní} -\textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{img/cpt-screenshots/portfolio-entry-detail.png}} + \caption{Obrazovka zobrazující detail položky portfólia} + \label{fig:entry-detail} + \end{figure} + + Po kliknutí na řádku transakce se objeví okno s výpisem ostatních informací o transakci, jako například její hodnota + v době vzniku či poplatek za její uskutečnění. + + \begin{figure}[!ht] + \centering + {\includegraphics[width=0.7\textwidth]{img/cpt-screenshots/portfolio-order-detail.png}} + \caption{Okno s detailem vybrané transakce} + \label{fig:transaction-detail} + \end{figure} + + \newpage + \subsection{Formulář pro vytvoření či editaci transakce} + K vytvoření či editaci transakce slouží jednoduchý formulář, do kterého je třeba zadat následující údaje: + + \begin{itemize} + \item cena za jednu minci kryptoměny + \item obchodovaný objem transakce + \item poplatek za uskutečnění transakce + \item datum uskutečnění transakce + \end{itemize} + Dále se v tomto formuláři nachází zaškrtávací pole pro specifikaci, zdali daná transakce představovala nákup či prodej. + + Ve spodní části formuláře se nachází tlačítka pro odeslání a obnovení formuláře. + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{img/cpt-screenshots/order-form.png}} + \caption{Formulář pro editaci či vytvoření transakce} + \label{fig:transaction-form} + \end{figure} + + \subsection{Obrazovka správy položek portfólia} + Možnost přidávat či odebírat položky z portfólia umožňuje obrazovka, na které je zobrazena tabulka obsahující seznam + kryptoměn, které lze v aplikaci sledovat. + U každé kryptoměny se nachází tlačtíko pro její odebrání či přidání do portfólia. + + Dostupné kryptoměny lze filtrovat, a to tak, že se do vstupního pole nad tabulkou zadá zkratka hledaná kryptoměny, kdy po stisknutí klávesy \texttt{ENTER} se tato kryptoměna vyhledá. -\printbibliography + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{img/cpt-screenshots/portfolio-entry-mngmt.png}} + \caption{Obrazovka správy položek portfólia} + \label{fig:portfolio-entry-mngmnt} + \end{figure} + \printbibliography \end{document} diff --git a/doc/img/app-diagram.pdf b/doc/img/app-diagram.pdf new file mode 100644 index 0000000..313b25c Binary files /dev/null and b/doc/img/app-diagram.pdf differ diff --git a/doc/img/cpt-screenshots/order-form.png b/doc/img/cpt-screenshots/order-form.png new file mode 100644 index 0000000..e641cf6 Binary files /dev/null and b/doc/img/cpt-screenshots/order-form.png differ diff --git a/doc/img/cpt-screenshots/portfolio-detail.png b/doc/img/cpt-screenshots/portfolio-detail.png new file mode 100644 index 0000000..6d23a7c Binary files /dev/null and b/doc/img/cpt-screenshots/portfolio-detail.png differ diff --git a/doc/img/cpt-screenshots/portfolio-entry-detail.png b/doc/img/cpt-screenshots/portfolio-entry-detail.png new file mode 100644 index 0000000..bce9d50 Binary files /dev/null and b/doc/img/cpt-screenshots/portfolio-entry-detail.png differ diff --git a/doc/img/cpt-screenshots/portfolio-entry-mngmt.png b/doc/img/cpt-screenshots/portfolio-entry-mngmt.png new file mode 100644 index 0000000..e628997 Binary files /dev/null and b/doc/img/cpt-screenshots/portfolio-entry-mngmt.png differ diff --git a/doc/img/cpt-screenshots/portfolio-form.png b/doc/img/cpt-screenshots/portfolio-form.png new file mode 100644 index 0000000..b83f04a Binary files /dev/null and b/doc/img/cpt-screenshots/portfolio-form.png differ diff --git a/doc/img/cpt-screenshots/portfolio-list.png b/doc/img/cpt-screenshots/portfolio-list.png new file mode 100644 index 0000000..ee94315 Binary files /dev/null and b/doc/img/cpt-screenshots/portfolio-list.png differ diff --git a/doc/img/cpt-screenshots/portfolio-order-detail.png b/doc/img/cpt-screenshots/portfolio-order-detail.png new file mode 100644 index 0000000..da2bba8 Binary files /dev/null and b/doc/img/cpt-screenshots/portfolio-order-detail.png differ