diff --git a/CryptoStatsSource/CoingeckoSource.cs b/CryptoStatsSource/CoingeckoSource.cs index f3cd128..c834163 100644 --- a/CryptoStatsSource/CoingeckoSource.cs +++ b/CryptoStatsSource/CoingeckoSource.cs @@ -10,20 +10,27 @@ 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>(); } diff --git a/CryptoStatsSource/CryptoNameResolver.cs b/CryptoStatsSource/CryptoNameResolver.cs index 06f1688..1e1cd40 100644 --- a/CryptoStatsSource/CryptoNameResolver.cs +++ b/CryptoStatsSource/CryptoNameResolver.cs @@ -1,39 +1,61 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using CryptoStatsSource.model; namespace CryptoStatsSource { - public interface ICryptoNameResolver + public interface ICryptocurrencyResolver { - public Task Resolve(string symbol); + /// + /// 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 CryptoNameResolverImpl : ICryptoNameResolver + public class CryptocurrencyResolverImpl : ICryptocurrencyResolver { + // used for retrieving cryptocurrency info private ICryptoStatsSource _cryptoStatsSource; - private Dictionary _symbolToNameMap; + + // a dictionary mapping symbols to cryptocurrencies + private Dictionary _nameToCryptocurrencyDictionary; - public CryptoNameResolverImpl(ICryptoStatsSource cryptoStatsSource) + /// CryptoStatsSource interface to be used + public CryptocurrencyResolverImpl(ICryptoStatsSource cryptoStatsSource) { _cryptoStatsSource = cryptoStatsSource; } public async Task Refresh() { - // TODO improve this - _symbolToNameMap = new(); - (await _cryptoStatsSource.GetAvailableCryptocurrencies()).ForEach(c => - _symbolToNameMap.TryAdd(c.Symbol, c.Name)); + // 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) + public async Task Resolve(string symbol) { - if (_symbolToNameMap?.GetValueOrDefault(symbol) == null) await Refresh(); + // refresh the dictionary if the symbol was not found in it + if (_nameToCryptocurrencyDictionary?.GetValueOrDefault(symbol) == null) await Refresh(); - return _symbolToNameMap.GetValueOrDefault(symbol, null); + return _nameToCryptocurrencyDictionary.GetValueOrDefault(symbol, null); } } } \ No newline at end of file diff --git a/CryptoStatsSource/CryptoStatsSource.cs b/CryptoStatsSource/CryptoStatsSource.cs index 2c46bef..53d97a7 100644 --- a/CryptoStatsSource/CryptoStatsSource.cs +++ b/CryptoStatsSource/CryptoStatsSource.cs @@ -6,8 +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/Model/MarketEntry.cs b/CryptoStatsSource/Model/MarketEntry.cs deleted file mode 100644 index 1735c94..0000000 --- a/CryptoStatsSource/Model/MarketEntry.cs +++ /dev/null @@ -1,12 +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); - - public record Cryptocurrency(string Id, string Symbol, string Name); -} \ 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 d071264..821c86e 100644 --- a/Database/SqlSchema.cs +++ b/Database/SqlSchema.cs @@ -4,34 +4,55 @@ namespace Database { public class SqlSchema { - // TODO column names into constants + 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, - currency_code INTEGER 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, - 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 5523e82..93d7bb5 100644 --- a/Model/Model.cs +++ b/Model/Model.cs @@ -3,10 +3,58 @@ namespace Model { + /// + /// 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); + /// + /// 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); + /// + /// 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) { @@ -21,11 +69,13 @@ public virtual bool Equals(MarketOrder? other) } } + /// + /// 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 48462bc..891b8d5 100644 --- a/README.md +++ b/README.md @@ -1,14 +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. - -- what's working: - - most of the data layer implemented via SQLite database - - integration tests ready - - wrapper over SqlKata library - - CoinGecko datasource implemented - - integration tests ready - - Blazor app startup with DI - - fetching basic cryptocurrency stats via CG datasource and showing it on the "Fetch Data" page +![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``` - \ No newline at end of file + diff --git a/Repository/IRepository.cs b/Repository/IRepository.cs index bdb82bb..86b0142 100644 --- a/Repository/IRepository.cs +++ b/Repository/IRepository.cs @@ -3,36 +3,91 @@ 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); - public int DeletePortfolioEntryOrders(int portfolioEntryOrder); + + /// + /// 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 { } + /// + /// 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); } -} \ No newline at end of file +} diff --git a/Repository/SqlKataMarketOrderRepository.cs b/Repository/SqlKataMarketOrderRepository.cs index bac9bbe..05cf5d0 100644 --- a/Repository/SqlKataMarketOrderRepository.cs +++ b/Repository/SqlKataMarketOrderRepository.cs @@ -7,38 +7,54 @@ namespace Repository { + /// + /// Implements the IMarketOrderRepository by extending SqlKataRepository and implementing necessary methods + /// public class SqlKataMarketOrderRepository : SqlKataRepository, IMarketOrderRepository { + // 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, "market_orders") + + public SqlKataMarketOrderRepository(SqlKataDatabase db) : base(db, SqlSchema.TableMarketOrders) { } - protected override int _getEntryId(MarketOrder entry) => entry.Id; + protected override int GetEntryId(MarketOrder entry) => entry.Id; - public override object ToRow(MarketOrder entry) + protected override object ToRow(MarketOrder entry) { return new { + // 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, }; } - public override MarketOrder FromRow(dynamic d) => - 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); + 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) => - RowsToObjects(Db.Get().Query(tableName).Where("portfolio_entry_id", portfolioEntryId).Get()); + // implement the method using the WHERE statement + RowsToObjects(Db.Get().Query(TableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId) + .Get()); - public int DeletePortfolioEntryOrders(int portfolioEntryOrder) => - Db.Get().Query(tableName).Where("portfolio_entry_id", portfolioEntryOrder).Delete(); + 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 fe12724..9ba51b1 100644 --- a/Repository/SqlKataPortfolioEntryRepository.cs +++ b/Repository/SqlKataPortfolioEntryRepository.cs @@ -5,26 +5,29 @@ namespace Repository { + /// + /// 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("portfolio_id", portfolioId).Get()); + RowsToObjects(Db.Get().Query(TableName).Where(SqlSchema.PortfolioEntriesPortfolioId, portfolioId).Get()); public int DeletePortfolioEntries(int portfolioId) => - Db.Get().Query(tableName).Where("portfolio_id", portfolioId).Delete(); + 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 1a19e1e..c58e5e9 100644 --- a/Repository/SqlKataPortfolioRepository.cs +++ b/Repository/SqlKataPortfolioRepository.cs @@ -3,22 +3,26 @@ namespace Repository { + + /// + /// 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, currency_code = (int) entry.Currency }; - public override Portfolio FromRow(dynamic d) => + 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 22aa4f4..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,68 +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; - protected 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)).Delete(); - 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).FirstOrDefault(); - 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 GetAll() => RowsToObjects(Db.Get().Query(tableName).Select().Get()); + // select all rows from the database and converts them to objects of type T + public List GetAll() => RowsToObjects(Db.Get().Query(TableName).Select().Get()); - protected List RowsToObjects(IEnumerable rows) - { - List items = new List(); - foreach (var row in rows) - { - items.Add(FromRow(row)); - } - - return items; - } - } - - 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 index fb4b7b2..4bf5ee8 100644 --- a/Services/Services/MarketOrderService.cs +++ b/Services/Services/MarketOrderService.cs @@ -1,30 +1,68 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; 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 IMarketOrderRepository _marketOrderRepository; + private readonly IMarketOrderRepository _marketOrderRepository; public MarketOrderServiceImpl(IMarketOrderRepository marketOrderRepository) { @@ -34,8 +72,13 @@ public MarketOrderServiceImpl(IMarketOrderRepository 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}; } diff --git a/Services/Services/PortfolioEntryService.cs b/Services/Services/PortfolioEntryService.cs index ee3f219..4c24b0f 100644 --- a/Services/Services/PortfolioEntryService.cs +++ b/Services/Services/PortfolioEntryService.cs @@ -6,25 +6,62 @@ 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); - bool UpdatePortfolio(PortfolioEntry entry); + /// + /// 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 { - private IPortfolioEntryRepository _portfolioEntryRepository; - private IMarketOrderService _marketOrderService; + // 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) { @@ -32,21 +69,25 @@ public PortfolioEntryServiceImpl(IPortfolioEntryRepository portfolioEntryReposit _marketOrderService = marketOrderService; } - public PortfolioEntry CreatePortfolioEntry(string symbol, int portfolioId) { + // create a new instance of the `PortfolioEntry` class var portfolioEntry = new PortfolioEntry(symbol, portfolioId); - portfolioEntry = portfolioEntry with {Id = _portfolioEntryRepository.Add(portfolioEntry)}; - return portfolioEntry; + + // 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 UpdatePortfolio(PortfolioEntry entry) => _portfolioEntryRepository.Update(entry); + public bool UpdatePortfolioEntry(PortfolioEntry entry) => _portfolioEntryRepository.Update(entry); public PortfolioEntry GetPortfolioEntry(int id) => _portfolioEntryRepository.Get(id); @@ -54,11 +95,14 @@ public bool DeletePortfolioEntry(PortfolioEntry entry) 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); } } diff --git a/Services/Services/PortfolioService.cs b/Services/Services/PortfolioService.cs index 42e7905..ed50893 100644 --- a/Services/Services/PortfolioService.cs +++ b/Services/Services/PortfolioService.cs @@ -6,55 +6,87 @@ namespace Services { + /// + /// A service that is responsible for managing portfolios and storing them to a persistent repository. + /// public interface IPortfolioService { + /// + /// 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; - private IPortfolioEntryService _portfolioEntryService; + // 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, IPortfolioEntryService portfolioEntryService) + public PortfolioServiceImpl(IPortfolioRepository portfolioRepository, + IPortfolioEntryService portfolioEntryService) { - this._portfolioRepository = portfolioRepository; - this._portfolioEntryService = portfolioEntryService; + _portfolioRepository = portfolioRepository; + _portfolioEntryService = portfolioEntryService; } public Portfolio CreatePortfolio(string name, string description, Currency currency) { - var potfolio = new Portfolio(name, description, currency); - var id = _portfolioRepository.Add(potfolio); - return potfolio with + // create a new `Portfolio` class instance + var portfolio = new Portfolio(name, description, currency); + return portfolio with { - Id = id + 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) - { - return _portfolioRepository.Update(portfolio); - } + public bool UpdatePortfolio(Portfolio portfolio) => _portfolioRepository.Update(portfolio); - public Portfolio GetPortfolio(int id) - { - return _portfolioRepository.Get(id); - } + public Portfolio GetPortfolio(int id) => _portfolioRepository.Get(id); - public List GetPortfolios() - { - return _portfolioRepository.GetAll(); - } + public List GetPortfolios() => _portfolioRepository.GetAll(); + } } \ No newline at end of file diff --git a/Services/Services/SummaryService.cs b/Services/Services/SummaryService.cs index ef2ea8b..70570bb 100644 --- a/Services/Services/SummaryService.cs +++ b/Services/Services/SummaryService.cs @@ -6,14 +6,48 @@ 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); } @@ -21,14 +55,17 @@ 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); } @@ -39,7 +76,7 @@ public ISummaryService.Summary GetAverageOfSummaries(IEnumerable portfo 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; } @@ -86,8 +131,10 @@ public ISummaryService.Summary GetPortfolioEntrySummary(List portfo 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); @@ -95,8 +142,8 @@ public ISummaryService.Summary GetPortfolioEntrySummary(List portfo 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 index 0b7968a..35600fb 100644 --- a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs +++ b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs @@ -6,7 +6,7 @@ namespace Tests.Integration.CryptoStatsSource public class ResolveNameTest { private readonly CoingeckoSource _source; - private readonly CryptoNameResolverImpl _resolver; + private readonly CryptocurrencyResolverImpl _resolver; public ResolveNameTest() { @@ -17,10 +17,10 @@ public ResolveNameTest() [Fact] public async void SimpleThreeEntries() { - Assert.Equal("Bitcoin", await _resolver.Resolve("btc")); - Assert.Equal("Cardano", await _resolver.Resolve("ada")); - Assert.Equal("Litecoin", await _resolver.Resolve("ltc")); - Assert.Equal("Ethereum", await _resolver.Resolve("eth")); + 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")); } } diff --git a/Tests/Integration/Repository/MarketOrderTest.cs b/Tests/Integration/Repository/MarketOrderTest.cs index 7b34c8a..c30ce59 100644 --- a/Tests/Integration/Repository/MarketOrderTest.cs +++ b/Tests/Integration/Repository/MarketOrderTest.cs @@ -239,7 +239,62 @@ public void Delete_Deletes() // assert Assert.Null(marketOrderRepositoryFixture.MarketOrderRepository.Get(marketOrder1.Id)); Assert.Equal(marketOrder2, marketOrderRepositoryFixture.MarketOrderRepository.Get(marketOrder2.Id)); - Assert.Equal(1, marketOrderRepositoryFixture.MarketOrderRepository.GetAll().Count); + 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 7439f78..d475ff6 100644 --- a/Tests/Integration/Repository/PortfolioEntryTest.cs +++ b/Tests/Integration/Repository/PortfolioEntryTest.cs @@ -176,7 +176,7 @@ 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); @@ -213,7 +213,7 @@ public void Delete_Deletes() { Id = _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(firstEntry) }; - + secondEntry = secondEntry with { Id = _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Add(secondEntry) @@ -223,7 +223,49 @@ public void Delete_Deletes() // assert Assert.Null(_portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(firstEntry.Id)); Assert.Equal(secondEntry, _portfolioEntryRepositoryFixture.PortfolioEntryRepository.Get(secondEntry.Id)); - Assert.Equal(1, _portfolioEntryRepositoryFixture.PortfolioEntryRepository.GetAll().Count); + 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 + + 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/Unit/Service/MarketOrderServiceTest.cs b/Tests/Unit/Service/MarketOrderServiceTest.cs index a65d603..d0f4fe5 100644 --- a/Tests/Unit/Service/MarketOrderServiceTest.cs +++ b/Tests/Unit/Service/MarketOrderServiceTest.cs @@ -118,5 +118,20 @@ public void Delete_CallsRepository() // 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 index fdcb145..8e030e0 100644 --- a/Tests/Unit/Service/PortfolioEntryServiceTest.cs +++ b/Tests/Unit/Service/PortfolioEntryServiceTest.cs @@ -86,7 +86,7 @@ public void Update_CallsRepository() var service = new PortfolioEntryServiceImpl(repositoryMock.Object, marketOrderServiceMock.Object); // act - var updated = service.UpdatePortfolio(entryToBeUpdated); + var updated = service.UpdatePortfolioEntry(entryToBeUpdated); // assert Assert.True(updated); diff --git a/Tests/Unit/Service/SummaryServiceTest.cs b/Tests/Unit/Service/SummaryServiceTest.cs index 497af44..6e2f055 100644 --- a/Tests/Unit/Service/SummaryServiceTest.cs +++ b/Tests/Unit/Service/SummaryServiceTest.cs @@ -70,6 +70,21 @@ public void GetMarketOrderSummary_WithFee_InLoss_Returns_Correct_Summary() ), 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] @@ -126,6 +141,23 @@ public void GetPortfolioEntrySummary_InProfit_WithSell_Returns_Correct_Summary() ), 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() { @@ -159,5 +191,21 @@ public void GetPortfolioSummary_Returns_Correct_Summary() 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 index a6d25ab..a3e2a07 100644 --- a/Utils/CurrencyUtils.cs +++ b/Utils/CurrencyUtils.cs @@ -7,6 +7,11 @@ 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) @@ -22,6 +27,12 @@ public static string GetCurrencyLabel(Currency currency) 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)); @@ -50,6 +61,13 @@ public static string Format(decimal value, Currency currency) 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); } diff --git a/Utils/DecimalUtils.cs b/Utils/DecimalUtils.cs index c7ecf3b..5c5a3f5 100644 --- a/Utils/DecimalUtils.cs +++ b/Utils/DecimalUtils.cs @@ -5,17 +5,20 @@ namespace Utils { public static class DecimalUtils { - private static NumberFormatInfo whitespaceSeparatorNfi = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone(); + // 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() { - whitespaceSeparatorNfi.NumberGroupSeparator = " "; + // make the number format use whitespace as thousands separator + WhitespaceSeparatorNf.NumberGroupSeparator = " "; } - public static string FormatTrimZeros(decimal value) => value.ToString("#,0.#############", whitespaceSeparatorNfi); + public static string FormatTrimZeros(decimal value) => value.ToString("#,0.#############", WhitespaceSeparatorNf); - public static string FormatTwoDecimalPlaces(decimal value) => value.ToString("#,0.00", whitespaceSeparatorNfi); + public static string FormatTwoDecimalPlaces(decimal value) => value.ToString("#,0.00", WhitespaceSeparatorNf); - public static string FormatFiveDecimalPlaces(decimal value) => value.ToString("#,0.00000", whitespaceSeparatorNfi); + public static string FormatFiveDecimalPlaces(decimal value) => value.ToString("#,0.00000", WhitespaceSeparatorNf); public static string FormatTwoDecimalPlacesWithPlusSign(decimal value) => (value > 0 ? "+" : "") + FormatTwoDecimalPlaces(value); diff --git a/Utils/EnumUtils.cs b/Utils/EnumUtils.cs index 33a8058..849bba9 100644 --- a/Utils/EnumUtils.cs +++ b/Utils/EnumUtils.cs @@ -4,8 +4,13 @@ namespace Utils { - public class EnumUtils + 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(); } 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 3b38d9e..6033d80 100644 --- a/WebFrontend/App.razor +++ b/WebFrontend/App.razor @@ -1,12 +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 index f4b6c26..2cbca14 100644 --- a/WebFrontend/Pages/EditMarketOrder.razor +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -17,52 +17,75 @@
-
- Back +
+
+ 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; - protected EntryForm.NewOrderModel InitialOrderModel; - protected Portfolio ActivePortfolio; - protected PortfolioEntry ActiveEntry; - protected MarketOrder ActiveMarketOrder; + // portfolio entry the order will belong to + private PortfolioEntry _activeEntry; + + // edited order + private MarketOrder _activeMarketOrder; protected override void OnInitialized() { - ActiveMarketOrder = MarketOrderService.GetMarketOrder(OrderId); - ActiveEntry = PortfolioEntrySerivce.GetPortfolioEntry(ActiveMarketOrder.PortfolioEntryId); - ActivePortfolio = PortfolioService.GetPortfolio(ActiveEntry.PortfolioId); - InitialOrderModel = new(); - InitialOrderModel.Fee = ActiveMarketOrder.Fee; - InitialOrderModel.Size = ActiveMarketOrder.Size; - InitialOrderModel.FilledPrice = ActiveMarketOrder.FilledPrice; - InitialOrderModel.OrderDate = ActiveMarketOrder.Date; - InitialOrderModel.SellOrder = !ActiveMarketOrder.Buy; + // 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(EntryForm.NewOrderModel formModel) + private void OnCreateOrderFormSubmit(OrderForm.OrderFormModel formFormModel) { - MarketOrderService.UpdateMarketOrder(ActiveMarketOrder with { - FilledPrice = formModel.FilledPrice, - Fee = formModel.Fee, - Size = formModel.Size, - Date = formModel.OrderDate, - Buy = !formModel.SellOrder + // 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, "", ""); - NavigationManager.NavigateTo($"/entries/{ActiveEntry.Id}"); + + // 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 index 2a289d8..ed5d490 100644 --- a/WebFrontend/Pages/EditPortfolio.razor +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -5,7 +5,7 @@ @inject IPortfolioService PortfolioService @inject IMatDialogService MatDialogService @inject IMatToaster Toaster -@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager +@inject NavigationManager NavigationManager
-
-
+
+
Portfolios - @if (PortfoliosWithEntries != null) + @if (_portfoliosWithEntries == null) + { + + } + else if (_portfoliosWithEntries.Count < 1) + { + + No portfolios were found. +
+ +
+
+ } + else { - @foreach (var portfolioWithEntries in PortfoliosWithEntries) + @foreach (var portfolioWithEntries in _portfoliosWithEntries) { @@ -76,20 +96,17 @@ } } - else - { - - }
-
+
- + @code { - protected List>> PortfoliosWithEntries; + // list of portfolios with entries mapped to them + private List>> _portfoliosWithEntries; protected record PortfolioEntryRow(string symbol, decimal currentPrice, decimal relativeChange, decimal percentage); @@ -100,7 +117,7 @@ private void LoadPortfolios() { - PortfoliosWithEntries = PortfolioService.GetPortfolios().Select( + _portfoliosWithEntries = PortfolioService.GetPortfolios().Select( portfolio => new Tuple>( portfolio, PortfolioEntryService.GetPortfolioEntries(portfolio.Id) @@ -115,11 +132,15 @@ 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, "", ""); } @@ -129,7 +150,7 @@ { NavigationManager.NavigateTo($"/newportfolioentry/{portfolio.Id}"); } - + private void ViewPortfolio(Portfolio portfolio) { NavigationManager.NavigateTo($"/portfolios/{portfolio.Id}"); diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor index ba6da76..9c18a8d 100644 --- a/WebFrontend/Pages/NewMarketOrder.razor +++ b/WebFrontend/Pages/NewMarketOrder.razor @@ -1,12 +1,12 @@ @page "/newmarketorder/{entryId:int}" -@using Model @using Services +@using Model @inject IPortfolioService PortfolioService @inject IPortfolioEntryService PortfolioEntrySerivce @inject IMarketOrderService MarketOrderService @inject IMatDialogService MatDialogService @inject IMatToaster Toaster -@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager +@inject NavigationManager NavigationManager
- -
-
+
+
BackPortfolio Detail - @if (ActivePortfolio != null) + @if (_activePortfolio != null) {
- @ActivePortfolio.Name - + @_activePortfolio.Name + - @ActivePortfolio.Description + @_activePortfolio.Description
- @if (PortfolioSummary != null) + @if (_portfolioSummary != null) {
- +
- +
} else @@ -73,13 +78,13 @@ - @if (PortfolioEntryRows == null) + @if (_portfolioEntryRows == null) { } - else if (PortfolioEntryRows.Count > 0) + else if (_portfolioEntryRows.Count > 0) { - + Coin Price @@ -89,18 +94,23 @@ @context.Symbol.ToUpper() -
@(CurrencyUtils.Format(context.CurrentPrice, ActivePortfolio.Currency))
+
@(CurrencyUtils.Format(context.CurrentPrice, _activePortfolio.Currency))
@DecimalUtils.FormatTwoDecimalPlaces(context.RelativeChange)%
- @(CurrencyUtils.Format(context.MarketValue, ActivePortfolio.Currency)) (@(DecimalUtils.FormatTwoDecimalPlaces(context.Percentage))%) + @(CurrencyUtils.Format(context.MarketValue, _activePortfolio.Currency)) (@(DecimalUtils.FormatTwoDecimalPlaces(context.Percentage))%)
} else { - No portfolio entries found... + + No portfolio entries were found. +
+ +
+
} } else @@ -108,31 +118,42 @@ }
-
+
- + @code { + // id of the portfolio whose detail should be shown [Parameter] public int PortfolioId { get; set; } - protected Portfolio ActivePortfolio; + // portfolio whose detail should be shown + private Portfolio _activePortfolio; - protected ISummaryService.Summary PortfolioSummary = null; + // summary of the portfolio + private ISummaryService.Summary _portfolioSummary; - protected List ActivePortfolioEntries; + // entries of the portfolio + private List _activePortfolioEntries; - protected List PortfolioEntryRows; + // 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); - ActivePortfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); + _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); + if (_activePortfolio == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + + _activePortfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); _loadEntryInfo(); } @@ -140,64 +161,58 @@ private async void _loadEntryInfo() { // resolve names of all portfolio entries - await CryptoNameResolver.Refresh(); - var portfolioEntriesNames = await Task.WhenAll( - ActivePortfolioEntries.Select( - async entry => (await CryptoNameResolver.Resolve(entry.Symbol)).ToLower()) + 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(), portfolioEntriesNames + 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); - // TODO fix low low low market cap non existing altcoins // compute portfolio entry summaries - var entrySummaries = ActivePortfolioEntries.Select( + var entrySummaries = _activePortfolioEntries.Select( portfolioEntry => { // find the evaluation of the entry's asset var marketEntry = symbolsToMarketEntries.GetValueOrDefault(portfolioEntry.Symbol); - if (marketEntry == null) - { - // TODO market entry is null - } - // 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); + _portfolioSummary = SummaryService.GetPortfolioSummary(entrySummaries); // if the cost of the summary is zero, set the relative change to zero - if (PortfolioSummary.Cost == 0) + if (_portfolioSummary.Cost == 0) { - PortfolioSummary = PortfolioSummary with { + _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( + _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), + 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 @@ -208,7 +223,8 @@ tuple.Second.Id ) ).ToList(); - + + // update the UI StateHasChanged(); } @@ -216,8 +232,8 @@ { 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 index 0b5f986..0546ac7 100644 --- a/WebFrontend/Pages/PortfolioEntryDetail.razor +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -4,13 +4,13 @@ @using Utils @using CryptoStatsSource @using CryptoStatsSource.model -@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager +@inject NavigationManager NavigationManager @inject IMatDialogService MatDialogService @inject IMatToaster Toaster @inject IPortfolioService PortfolioService @inject IPortfolioEntryService PortfolioEntryService @inject IMarketOrderService MarketOrderService -@inject ICryptoNameResolver CryptoNameResolver +@inject ICryptocurrencyResolver CryptocurrencyResolver @inject ICryptoStatsSource CryptoStatsSource @inject ISummaryService SummaryService @@ -31,37 +31,46 @@ margin-top: 0px; margin-bottom: 0px; } + .app-fab--absolute { position: fixed; bottom: 1rem; right: 1rem; } + + .mat-paper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1em; + }
- Back + BackPortfolio Entry - @if(ActivePortfolioEntry != null) + @if(_activePortfolioEntry != null) {
- @if (PortfolioEntryName != null) + @if (_portfolioCryptocurrencyEntry != null) { - @PortfolioEntryName + @_portfolioCryptocurrencyEntry.Name } else { } - - @if (CurrentEntryAssetMarketEntry != null) + + @if (_currentEntryAssetMarketEntry != null) { - 1 @ActivePortfolioEntry.Symbol.ToUpper() = @CurrencyUtils.Format(CurrentEntryAssetMarketEntry.CurrentPrice, ActivePortfolio.Currency) + 1 @_activePortfolioEntry.Symbol.ToUpper() = @CurrencyUtils.Format(_currentEntryAssetMarketEntry.CurrentPrice, _activePortfolio.Currency) } else { @@ -72,31 +81,31 @@
- @if (EntrySummary != null) + @if (_entrySummary != null) {
- +
- +
- +
- +
- +
- +
@@ -114,17 +123,17 @@ }
- @if (TableRowsItems == null) + @if (_tableRowsItems == null) { } - else if (TableRowsItems.Count == 0) + else if (_tableRowsItems.Count == 0) { - No orders found... + No market orders were found.
} else { - + Date Size @@ -140,21 +149,21 @@ @if (@context.Item1.Buy) { -
@context.Item1.Size @ActivePortfolioEntry.Symbol.ToUpper()
+
@context.Item1.Size @_activePortfolioEntry.Symbol.ToUpper()
} else { -
-@context.Item1.Size @ActivePortfolioEntry.Symbol.ToUpper()
+
-@context.Item1.Size @_activePortfolioEntry.Symbol.ToUpper()
} -
@(CurrencyUtils.Format(context.Item1.FilledPrice, ActivePortfolio.Currency))
+
@(CurrencyUtils.Format(context.Item1.FilledPrice, _activePortfolio.Currency))
-
@CurrencyUtils.Format(context.Item2.MarketValue, ActivePortfolio.Currency)
+
@CurrencyUtils.Format(context.Item2.MarketValue, _activePortfolio.Currency)
-
@CurrencyUtils.Format(context.Item2.AbsoluteChange, ActivePortfolio.Currency) (@(DecimalUtils.FormatTwoDecimalPlaces(context.Item2.RelativeChange * 100))%)
+
@CurrencyUtils.Format(context.Item2.AbsoluteChange, _activePortfolio.Currency) (@(DecimalUtils.FormatTwoDecimalPlaces(context.Item2.RelativeChange * 100))%)
@@ -167,36 +176,36 @@
- + - + Market Order Detail - @if (OrderToBeShown != null) + @if (_orderToBeShown != null) {
- +
- +
- +
- +
- +
- +
@@ -209,44 +218,65 @@ @code { - //SummaryService.GetMarketOrderSummary(OrderToBeShown, CurrentEntryAssetMarketEntry.CurrentPrice).RelativeChange, ActivePortfolio.Currency) + // ID of the entry whose detail should be displayed [Parameter] public int EntryId { get; set; } - protected Portfolio ActivePortfolio; - protected PortfolioEntry ActivePortfolioEntry; - protected MarketEntry CurrentEntryAssetMarketEntry; - protected string PortfolioEntryName = "Bitcoin"; - protected ISummaryService.Summary EntrySummary; - protected decimal TotalHoldings = 0; + // 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; - protected bool OrderDetailDialogIsOpen; - protected Tuple OrderToBeShown; + // total holdings of the active entry + private decimal _totalHoldings; - protected List> TableRowsItems; + // 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); + _activePortfolioEntry = PortfolioEntryService.GetPortfolioEntry(EntryId); + + if (_activePortfolioEntry == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } // get the entry's portfolio - ActivePortfolio = PortfolioService.GetPortfolio(ActivePortfolioEntry.PortfolioId); + _activePortfolio = PortfolioService.GetPortfolio(_activePortfolioEntry.PortfolioId); } protected override async Task OnInitializedAsync() { // resolve the name of the cryptocurrency (using the symbol) - PortfolioEntryName = await CryptoNameResolver.Resolve(ActivePortfolioEntry.Symbol); + _portfolioCryptocurrencyEntry = await CryptocurrencyResolver.Resolve(_activePortfolioEntry.Symbol); await UpdateEntrySummary(); } private void SetEntryLoading() { - CurrentEntryAssetMarketEntry = null; - TableRowsItems = null; - EntrySummary = null; + _currentEntryAssetMarketEntry = null; + _tableRowsItems = null; + _entrySummary = null; StateHasChanged(); } @@ -254,27 +284,27 @@ { // fetch the price of the entry's asset - // TODO null? - CurrentEntryAssetMarketEntry = (await CryptoStatsSource.GetMarketEntries( - CurrencyUtils.GetCurrencyLabel(ActivePortfolio.Currency).ToLower(), - PortfolioEntryName.ToLower() + _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); + var entryOrders = MarketOrderService.GetPortfolioEntryOrders(_activePortfolioEntry.Id); // compute summaries of all orders in the entry var entrySummaries = entryOrders.Select(order => - SummaryService.GetMarketOrderSummary(order, CurrentEntryAssetMarketEntry.CurrentPrice)); + SummaryService.GetMarketOrderSummary(order, _currentEntryAssetMarketEntry.CurrentPrice)); - TotalHoldings = entryOrders.Sum(order => order.Size * (order.Buy ? 1 : -1)); + // 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) + _tableRowsItems = entryOrders.Zip(entrySummaries) .Select(tuple => new Tuple(tuple.First, tuple.Second)).ToList(); // compute suummary of this entry - EntrySummary = SummaryService.GetPortfolioEntrySummary(entryOrders, CurrentEntryAssetMarketEntry.CurrentPrice); + _entrySummary = SummaryService.GetPortfolioEntrySummary(entryOrders, _currentEntryAssetMarketEntry.CurrentPrice); } public void EditMarketOrder(MarketOrder order) @@ -284,12 +314,16 @@ 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, "", ""); } @@ -297,15 +331,15 @@ void ShowOrderDetail(Tuple order) { - OrderToBeShown = order; - OrderDetailDialogIsOpen = true; + _orderToBeShown = order; + _orderDetailDialogIsOpen = true; StateHasChanged(); } void HideOrderDetail() { - OrderToBeShown = null; - OrderDetailDialogIsOpen = false; + _orderToBeShown = null; + _orderDetailDialogIsOpen = false; StateHasChanged(); } @@ -313,6 +347,7 @@ { if (obj != null) { + // order has been clicked, show its detail ShowOrderDetail((Tuple) obj) ; } } diff --git a/WebFrontend/Pages/NewPortfolioEntry.razor b/WebFrontend/Pages/PortfolioEntryManagement.razor similarity index 54% rename from WebFrontend/Pages/NewPortfolioEntry.razor rename to WebFrontend/Pages/PortfolioEntryManagement.razor index 2832448..47624c5 100644 --- a/WebFrontend/Pages/NewPortfolioEntry.razor +++ b/WebFrontend/Pages/PortfolioEntryManagement.razor @@ -11,37 +11,23 @@ @inject ICryptoStatsSource CryptoStatsSource; @inject IMatDialogService MatDialogService @inject IMatToaster Toaster -@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager - - +@inject NavigationManager NavigationManager
-
- Back to portfolioManage entries of @Portfolio.Name +
+
+ Back to portfolioManage entries of @_portfolio.Name - @if (AvailableCryptocurrenciesWithUsage == null) + @if (_availableCryptocurrenciesWithUsage == null) { } else { - @if (AvailableCryptocurrenciesWithUsage.Count > 0) + @if (_availableCryptocurrenciesWithUsage.Count > 0) { - @@ -71,10 +57,11 @@ } else { - No cryptocurrencies match the symbol \"@CryptocurrencyFilter\" + No cryptocurrencies match the symbol "@CryptocurrencyFilter" } }
+
@@ -87,6 +74,7 @@ set { _cryptocurrencyFilter = value; + // when setting the cryptocurrency symbol filter, do filter the list of available cryptos FilterCurrenciesBySymbol(value); this.StateHasChanged(); } @@ -94,8 +82,9 @@ private void FilterCurrenciesBySymbol(string value) { - FilteredCryptocurrencies = AvailableCryptocurrencies.FindAll(c => c.Symbol.Contains(value)); - UpdateAvailableCryptocurrencies(FilteredCryptocurrencies); + // filter by symbol + _filteredCryptocurrencies = _availableCryptocurrencies.FindAll(c => c.Symbol.Contains(value)); + UpdateAvailableCryptocurrencies(_filteredCryptocurrencies); } private string _cryptocurrencyFilter; @@ -103,57 +92,85 @@ [Parameter] public int PortfolioId { get; set; } - protected Portfolio Portfolio; - protected List PortfolioEntries; - - protected List AvailableCryptocurrencies; - protected List FilteredCryptocurrencies; - protected List> AvailableCryptocurrenciesWithUsage; + // 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() { - Portfolio = PortfolioService.GetPortfolio(PortfolioId); - PortfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); + // 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() { - AvailableCryptocurrencies = await CryptoStatsSource.GetAvailableCryptocurrencies(); - FilteredCryptocurrencies = AvailableCryptocurrencies; - UpdateAvailableCryptocurrencies(AvailableCryptocurrencies); + // 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()); - AvailableCryptocurrenciesWithUsage = availableCryptocurrencies.Select( + 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) { - Console.WriteLine("OnAddCurrencyClicked"); + // create a new portfolio entry var entry = PortfolioEntryService.CreatePortfolioEntry(cryptocurrency.Symbol, PortfolioId); - PortfolioEntries.Add(entry); - UpdateAvailableCryptocurrencies(FilteredCryptocurrencies); + _portfolioEntries.Add(entry); + + // update the UI + UpdateAvailableCryptocurrencies(_filteredCryptocurrencies); StateHasChanged(); - Toaster.Add($"{cryptocurrency.Symbol.ToUpper()} entry successfully added to {Portfolio.Name}.", MatToastType.Success, "", ""); + + // notify the user + Toaster.Add($"{cryptocurrency.Symbol.ToUpper()} entry successfully added to {_portfolio.Name}.", MatToastType.Success, "", ""); } private async void OnDeleteCurrencyClicked(Cryptocurrency cryptocurrency) { - Console.WriteLine("OnDeleteCurrencyClicked"); - // TODO display confirmation dialog only when entry orders exist + // 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) { - var entry = PortfolioEntries.Find(entry => entry.Symbol == cryptocurrency.Symbol); + // find the entry + var entry = _portfolioEntries.Find(entry => entry.Symbol == cryptocurrency.Symbol); + // delete the entry PortfolioEntryService.DeletePortfolioEntry(entry); - PortfolioEntries.Remove(entry); - UpdateAvailableCryptocurrencies(FilteredCryptocurrencies); + _portfolioEntries.Remove(entry); + // update the UI + UpdateAvailableCryptocurrencies(_filteredCryptocurrencies); StateHasChanged(); - Toaster.Add($"{cryptocurrency.Symbol.ToUpper()} entry successfully deleted from {Portfolio.Name}.", MatToastType.Success, "", ""); + 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/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 c96a196..0000000 --- a/WebFrontend/Shared/NavMenu.razor +++ /dev/null @@ -1,6 +0,0 @@ - -   Home -   New portfolio -   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/EntryForm.razor b/WebFrontend/Shared/OrderForm.razor similarity index 79% rename from WebFrontend/Shared/EntryForm.razor rename to WebFrontend/Shared/OrderForm.razor index bbde711..8ff3500 100644 --- a/WebFrontend/Shared/EntryForm.razor +++ b/WebFrontend/Shared/OrderForm.razor @@ -56,34 +56,44 @@ @code { + // model of the form [Parameter] - public NewOrderModel FormModel { get; set; } = new (); + public OrderFormModel FormModel { get; set; } = new (); - [Parameter] public EventCallback OnSubmitEventHandler { get; set; } + // 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 NewOrderModel + public class OrderFormModel { + // transaction filled price [Required] - [CustomValidation(typeof(NewOrderModel), nameof(NonZeroValue))] + [CustomValidation(typeof(OrderFormModel), nameof(NonZeroValue))] public decimal FilledPrice { get; set; } + // size of the transaction [Required] - [CustomValidation(typeof(NewOrderModel), nameof(NonZeroValue))] + [CustomValidation(typeof(OrderFormModel), nameof(NonZeroValue))] public decimal Size { get; set; } + // fee for the transaction [Required] - [CustomValidation(typeof(NewOrderModel), nameof(NonNegativeValue))] + [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() diff --git a/WebFrontend/Shared/PortfolioForm.razor b/WebFrontend/Shared/PortfolioForm.razor index d0a9a3c..99e7923 100644 --- a/WebFrontend/Shared/PortfolioForm.razor +++ b/WebFrontend/Shared/PortfolioForm.razor @@ -47,24 +47,30 @@ @code { + // model of the portfolio form [Parameter] - public NewPortfolioModel FormModel { get; set; } = new (Currency.Usd); + public PortfolioFormModel FormModel { get; set; } = new (Currency.Usd); - [Parameter] public EventCallback OnSubmitEventHandler { get; set; } + // 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 NewPortfolioModel + public class PortfolioFormModel { + // default currency to be used private Currency _defaultCurrency; - public NewPortfolioModel(Currency defaultCurrency) + public PortfolioFormModel(Currency defaultCurrency) { _defaultCurrency = defaultCurrency; SelectedCurrency = defaultCurrency; diff --git a/WebFrontend/Startup.cs b/WebFrontend/Startup.cs index 9a5096c..f52aa18 100644 --- a/WebFrontend/Startup.cs +++ b/WebFrontend/Startup.cs @@ -4,6 +4,7 @@ using CryptoStatsSource; using Database; using ElectronNET.API; +using ElectronNET.API.Entities; using MatBlazor; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -11,9 +12,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Model; using Repository; -using ServerSideBlazor.Data; using Services; using SqlKata.Compilers; @@ -35,7 +34,6 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddRazorPages(); services.AddServerSideBlazor(); - services.AddSingleton(); services.AddMatBlazor(); services.AddMatToaster(config => @@ -55,7 +53,7 @@ public void ConfigureServices(IServiceCollection services) var db = new SqlKataDatabase(dbConnection, new SqliteCompiler()); services.AddSingleton(ctx => db); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -92,7 +90,24 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); }); - Task.Run(async () => await Electron.WindowManager.CreateWindowAsync()); + + 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/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