From 18467ff328f75612a803f48de614605ef0e133d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 8 May 2021 21:40:55 +0200 Subject: [PATCH 01/71] Added a TODO --- Repository/IRepository.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Repository/IRepository.cs b/Repository/IRepository.cs index bdb82bb..74e6b7e 100644 --- a/Repository/IRepository.cs +++ b/Repository/IRepository.cs @@ -6,6 +6,7 @@ namespace Repository // TODO comments public interface IRepository { + // TODO remove from IRepository public object ToRow(T entry); public int Add(T entry); @@ -35,4 +36,4 @@ public interface IPortfolioEntryRepository : IRepository public int DeletePortfolioEntries(int portfolioId); } -} \ No newline at end of file +} From a2f99c669710442226d28230c94c86fde8db7905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 8 May 2021 22:36:06 +0200 Subject: [PATCH 02/71] Added documentation of the database layer --- doc/doc.tex | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index 8658d4f..a0ea024 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -29,6 +29,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 +47,7 @@ breakatwhitespace=true, tabsize=3 } - +\lstset{style=sharpc} \lstset{ frame=tb, language=XML, @@ -149,8 +151,7 @@ \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í. \begin{lstlisting} -$ curl -X GET "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_cur -rencies=usd" -H "accept: application/json" +\$ curl -X GET "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" -H "accept: application/json" { "bitcoin": { @@ -159,16 +160,56 @@ \subsection{Webový zdroj CoinGecko} } \end{lstlisting} -\section{Výběr databáze pro implementaci datové vrstvy aplikace} +\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í. + + +\section{Popis architektury vytvořené aplikace} +\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} + +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} + +\subsection{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(tableName).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 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}. + \section{Framework pro grafické rozhraní} \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} + \printbibliography From 464c3b316522f8d9a95f00de4a582473e4b721d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 8 May 2021 23:04:17 +0200 Subject: [PATCH 03/71] Added test description --- doc/doc.tex | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/doc.tex b/doc/doc.tex index a0ea024..21f5cb4 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -208,6 +208,31 @@ \subsection{Generování SQL dotazů} \section{Framework pro grafické rozhraní} \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} +\section{Oveř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}. + +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:}. + +\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} + \printbibliography From fe4bd10065e5ebabcca4ad7e9e28cac6af4dbd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 8 May 2021 23:09:29 +0200 Subject: [PATCH 04/71] Further description of the Tests project --- doc/doc.tex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index 21f5cb4..d4c18c7 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -212,9 +212,11 @@ \section{Oveř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}. +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:}. +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ískvá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 From 952a283ea9600b3d0e6a44af3e53b81903765a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 9 May 2021 09:29:37 +0200 Subject: [PATCH 05/71] Minor text changes --- doc/doc.tex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index d4c18c7..abe92a4 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -182,14 +182,14 @@ \subsection{Databázová vrstva} \end{itemize} -Ve vytvořené aplikaci jsou definovány následující repozitáře: +\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} -\subsection{Generování SQL dotazů} +\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}] @@ -205,6 +205,9 @@ \subsection{Generování SQL dotazů} 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 pro výpočet výkonu jednotlivých entit} +Aby bylo možné vypočítat výkon (výdělek či ztráta) jednotlivých entit (portfólio, položka portfólia či uskutečněný obchod), byla vytvořeno rozhraní \texttt{ISummaryService} a jeho implementace \texttt{SummaryServiceImpl}. + \section{Framework pro grafické rozhraní} \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} From 76a0437261dcd90cf48605ebf9f02b0ff1778a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Mon, 10 May 2021 11:01:18 +0200 Subject: [PATCH 06/71] Fixed an issue where market entries were fetched by cryptocurrency names instead of their IDs. Changed CryptoNameResolver to CryptocurrencyResolver. --- CryptoStatsSource/CryptoNameResolver.cs | 21 ++++++++++--------- CryptoStatsSource/Model/MarketEntry.cs | 7 ++++++- .../CryptoNameResolverTest.cs | 10 ++++----- WebFrontend/Pages/PortfolioDetail.razor | 14 ++++++------- WebFrontend/Pages/PortfolioEntryDetail.razor | 12 +++++------ WebFrontend/Startup.cs | 2 +- 6 files changed, 36 insertions(+), 30 deletions(-) diff --git a/CryptoStatsSource/CryptoNameResolver.cs b/CryptoStatsSource/CryptoNameResolver.cs index 06f1688..2f61973 100644 --- a/CryptoStatsSource/CryptoNameResolver.cs +++ b/CryptoStatsSource/CryptoNameResolver.cs @@ -1,22 +1,23 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using CryptoStatsSource.model; namespace CryptoStatsSource { - public interface ICryptoNameResolver + public interface ICryptocurrencyResolver { - public Task Resolve(string symbol); + public Task Resolve(string symbol); public Task Refresh(); } - public class CryptoNameResolverImpl : ICryptoNameResolver + public class CryptocurrencyResolverImpl : ICryptocurrencyResolver { private ICryptoStatsSource _cryptoStatsSource; - private Dictionary _symbolToNameMap; + private Dictionary _nameToCryptocurrencyDictionary; - public CryptoNameResolverImpl(ICryptoStatsSource cryptoStatsSource) + public CryptocurrencyResolverImpl(ICryptoStatsSource cryptoStatsSource) { _cryptoStatsSource = cryptoStatsSource; } @@ -24,16 +25,16 @@ public CryptoNameResolverImpl(ICryptoStatsSource cryptoStatsSource) public async Task Refresh() { // TODO improve this - _symbolToNameMap = new(); + _nameToCryptocurrencyDictionary = new(); (await _cryptoStatsSource.GetAvailableCryptocurrencies()).ForEach(c => - _symbolToNameMap.TryAdd(c.Symbol, c.Name)); + _nameToCryptocurrencyDictionary.TryAdd(c.Symbol, c)); } - public async Task Resolve(string symbol) + public async Task Resolve(string symbol) { - if (_symbolToNameMap?.GetValueOrDefault(symbol) == null) await Refresh(); + 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/Model/MarketEntry.cs b/CryptoStatsSource/Model/MarketEntry.cs index 1735c94..b55a0dd 100644 --- a/CryptoStatsSource/Model/MarketEntry.cs +++ b/CryptoStatsSource/Model/MarketEntry.cs @@ -1,9 +1,14 @@ +using System.Text.Json; 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); + [JsonProperty("price_change_24h", NullValueHandling = NullValueHandling.Ignore)] + float? PriceChange24H = 0f, + [JsonProperty("price_change_percentage_24h", NullValueHandling = NullValueHandling.Ignore)] + float? PriceChangePercentage24H = 0f + ); public record PriceEntry(string Id, string Symbol, string Name, decimal CurrentPrice, float PriceChangePercentage24H); diff --git a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs index 0b7968a..78189f6 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("btc", "btc", "Bitcoin"), await _resolver.Resolve("btc")); + Assert.Equal(new ("ada", "ada", "Cardano"), await _resolver.Resolve("ada")); + Assert.Equal(new ("ltc", "ltc", "Litecoin"), await _resolver.Resolve("ltc")); + Assert.Equal(new("eth", "eth", "Ethereum"), await _resolver.Resolve("eth")); Assert.Null(await _resolver.Resolve("abcefghbzbzrfoo")); } } diff --git a/WebFrontend/Pages/PortfolioDetail.razor b/WebFrontend/Pages/PortfolioDetail.razor index 34e807f..6661432 100644 --- a/WebFrontend/Pages/PortfolioDetail.razor +++ b/WebFrontend/Pages/PortfolioDetail.razor @@ -11,7 +11,7 @@ @inject IMarketOrderService MarketOrderService; @inject ICryptoStatsSource CryptoStatsSource; @inject ISummaryService SummaryService; -@inject ICryptoNameResolver CryptoNameResolver; +@inject ICryptocurrencyResolver CryptocurrencyResolver;
@@ -34,7 +42,15 @@
Portfolios - @if (PortfoliosWithEntries != null) + @if (PortfoliosWithEntries == null) + { + + } + else if (PortfoliosWithEntries.Count < 1) + { + No portfolios were found.
+ } + else { @foreach (var portfolioWithEntries in PortfoliosWithEntries) { @@ -76,10 +92,6 @@ } } - else - { - - }
diff --git a/WebFrontend/Pages/PortfolioDetail.razor b/WebFrontend/Pages/PortfolioDetail.razor index 6661432..0e8dcf9 100644 --- a/WebFrontend/Pages/PortfolioDetail.razor +++ b/WebFrontend/Pages/PortfolioDetail.razor @@ -31,6 +31,14 @@ bottom: 1rem; right: 1rem; } + + .mat-paper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1em; + }
@@ -100,7 +108,7 @@ } else { - No portfolio entries found... + No portfolio entries were found.
} } else diff --git a/WebFrontend/Pages/PortfolioEntryDetail.razor b/WebFrontend/Pages/PortfolioEntryDetail.razor index d613484..c38840b 100644 --- a/WebFrontend/Pages/PortfolioEntryDetail.razor +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -31,12 +31,21 @@ 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; + } +
@@ -120,7 +129,7 @@ } else if (TableRowsItems.Count == 0) { - No orders found... + No market orders were found.
} else { From 0618730428ec303613ef903edfde89a6c500cc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Mon, 10 May 2021 11:48:52 +0200 Subject: [PATCH 08/71] Changed FAB button text and icon on the Portfolio Detail page --- WebFrontend/Pages/PortfolioDetail.razor | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/WebFrontend/Pages/PortfolioDetail.razor b/WebFrontend/Pages/PortfolioDetail.razor index 0e8dcf9..53e6e29 100644 --- a/WebFrontend/Pages/PortfolioDetail.razor +++ b/WebFrontend/Pages/PortfolioDetail.razor @@ -43,8 +43,8 @@
-
-
+
+
BackPortfolio Detail @if (ActivePortfolio != null) { @@ -116,10 +116,10 @@ }
-
+
- + @code From 9f730ff8dbf35368a6f1c6ea357646dc33225e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 19:51:34 +0200 Subject: [PATCH 09/71] Added CryptoStatsSource project documentation --- CryptoStatsSource/CoingeckoSource.cs | 7 ++++ CryptoStatsSource/CryptoNameResolver.cs | 19 +++++++++- CryptoStatsSource/CryptoStatsSource.cs | 10 ++++++ CryptoStatsSource/Model/MarketEntry.cs | 17 --------- CryptoStatsSource/Model/SourceModel.cs | 46 +++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 18 deletions(-) delete mode 100644 CryptoStatsSource/Model/MarketEntry.cs create mode 100644 CryptoStatsSource/Model/SourceModel.cs 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 2f61973..feb1c0e 100644 --- a/CryptoStatsSource/CryptoNameResolver.cs +++ b/CryptoStatsSource/CryptoNameResolver.cs @@ -7,16 +7,30 @@ namespace CryptoStatsSource { public interface ICryptocurrencyResolver { + /// + /// Resolves a cryptocurrency based on the given cryptocurrency symbol. Returns null value when the symbol does + /// not map to any cryptocurrency. + /// + /// Symbol based on which the cryptocurrency should be resolved + /// A task containing resolved cryptocurrency when finished public Task Resolve(string symbol); + /// + /// Refreshes the database of cryptocurrencies mapped to their symbols + /// + /// A task that is finished when all cryptocurrencies are fetched and mapped to their symbols public Task Refresh(); } public class CryptocurrencyResolverImpl : ICryptocurrencyResolver { + // used for retrieving cryptocurrency info private ICryptoStatsSource _cryptoStatsSource; + + // a dictionary mapping symbols to cryptocurrencies private Dictionary _nameToCryptocurrencyDictionary; + /// CryptoStatsSource interface to be used public CryptocurrencyResolverImpl(ICryptoStatsSource cryptoStatsSource) { _cryptoStatsSource = cryptoStatsSource; @@ -24,14 +38,17 @@ public CryptocurrencyResolverImpl(ICryptoStatsSource cryptoStatsSource) public async Task Refresh() { - // TODO improve this + // initialize the dictionary _nameToCryptocurrencyDictionary = new(); + + // fetch all cryptocurrencies and add them to the dictionary using the symbol as a key (await _cryptoStatsSource.GetAvailableCryptocurrencies()).ForEach(c => _nameToCryptocurrencyDictionary.TryAdd(c.Symbol, c)); } public async Task Resolve(string symbol) { + // refresh the dictionary if the symbol was not found in it if (_nameToCryptocurrencyDictionary?.GetValueOrDefault(symbol) == null) await Refresh(); return _nameToCryptocurrencyDictionary.GetValueOrDefault(symbol, null); 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 b55a0dd..0000000 --- a/CryptoStatsSource/Model/MarketEntry.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json; -using Newtonsoft.Json; - -namespace CryptoStatsSource.model -{ - 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 - ); - - 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 From bfc97f9cb4d6fba4989840981e6a6d6b5348abd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 21:09:56 +0200 Subject: [PATCH 10/71] Started to ignore "binance-peg-cardano" cryptocurrency Fixed a test --- CryptoStatsSource/CryptoNameResolver.cs | 6 +++++- .../CryptoStatsSource/CryptoNameResolverTest.cs | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CryptoStatsSource/CryptoNameResolver.cs b/CryptoStatsSource/CryptoNameResolver.cs index feb1c0e..b1c7b9c 100644 --- a/CryptoStatsSource/CryptoNameResolver.cs +++ b/CryptoStatsSource/CryptoNameResolver.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using CryptoStatsSource.model; @@ -42,7 +43,10 @@ public async Task Refresh() _nameToCryptocurrencyDictionary = new(); // fetch all cryptocurrencies and add them to the dictionary using the symbol as a key - (await _cryptoStatsSource.GetAvailableCryptocurrencies()).ForEach(c => + (await _cryptoStatsSource.GetAvailableCryptocurrencies()) + // workaround till Coingecko removes binance-peg-cardano entry + .Where(c => c.Symbol != "ada" && c.Id != "binance-peg-cardano").ToList() + .ForEach(c => _nameToCryptocurrencyDictionary.TryAdd(c.Symbol, c)); } diff --git a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs index 78189f6..8c42f57 100644 --- a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs +++ b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs @@ -17,10 +17,10 @@ public ResolveNameTest() [Fact] public async void SimpleThreeEntries() { - Assert.Equal(new("btc", "btc", "Bitcoin"), await _resolver.Resolve("btc")); - Assert.Equal(new ("ada", "ada", "Cardano"), await _resolver.Resolve("ada")); - Assert.Equal(new ("ltc", "ltc", "Litecoin"), await _resolver.Resolve("ltc")); - Assert.Equal(new("eth", "eth", "Ethereum"), await _resolver.Resolve("eth")); + Assert.Equal(new("bitcoin", "btc", "Bitcoin"), await _resolver.Resolve("btc")); + Assert.Equal(new ("cardano", "ada", "Cardano"), await _resolver.Resolve("cardano")); + 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")); } } From 9d56dbbb40901ec186dd1fbb9205832b83db2273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 21:10:41 +0200 Subject: [PATCH 11/71] Improved SqlSchema.cs in such way that column/table names are now stored as constants. --- Database/SqlSchema.cs | 61 ++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/Database/SqlSchema.cs b/Database/SqlSchema.cs index d071264..4ff422e 100644 --- a/Database/SqlSchema.cs +++ b/Database/SqlSchema.cs @@ -4,34 +4,53 @@ namespace Database { public class SqlSchema { - // TODO column names into constants + public const string TablePortfolios = "portfolios"; + public const string PortfoliosId = "id"; + 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 = "id"; + public const string PortfolioEntriesSymbol = "symbol"; + public const string PortfolioEntriesPortfolioId = "portfolio_id"; + + public const string TableMarketOrders = "market_orders"; + public const string MarketOrdersId = "id"; + 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) ); "); From 6a4471e99d64121c8e2ab7c4af031bbbf903eaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 21:32:21 +0200 Subject: [PATCH 12/71] Changed SqlKataRepositories in such way that constants are now used instead of string literals when referencing column/table names --- Repository/SqlKataMarketOrderRepository.cs | 6 +++--- Repository/SqlKataPortfolioEntryRepository.cs | 6 +++--- Repository/SqlKataPortfolioRepository.cs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Repository/SqlKataMarketOrderRepository.cs b/Repository/SqlKataMarketOrderRepository.cs index bac9bbe..8690904 100644 --- a/Repository/SqlKataMarketOrderRepository.cs +++ b/Repository/SqlKataMarketOrderRepository.cs @@ -11,7 +11,7 @@ public class SqlKataMarketOrderRepository : SqlKataRepository, IMar { private const int DecimalPrecision = 100000000; - public SqlKataMarketOrderRepository(SqlKataDatabase db) : base(db, "market_orders") + public SqlKataMarketOrderRepository(SqlKataDatabase db) : base(db, SqlSchema.TableMarketOrders) { } @@ -36,9 +36,9 @@ public override MarketOrder FromRow(dynamic d) => (int) d.id, (int) d.portfolio_entry_id); public List GetAllByPortfolioEntryId(int portfolioEntryId) => - RowsToObjects(Db.Get().Query(tableName).Where("portfolio_entry_id", portfolioEntryId).Get()); + 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(); + Db.Get().Query(tableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryOrder).Delete(); } } \ No newline at end of file diff --git a/Repository/SqlKataPortfolioEntryRepository.cs b/Repository/SqlKataPortfolioEntryRepository.cs index fe12724..2211996 100644 --- a/Repository/SqlKataPortfolioEntryRepository.cs +++ b/Repository/SqlKataPortfolioEntryRepository.cs @@ -7,7 +7,7 @@ namespace Repository { public class SqlKataPortfolioEntryRepository : SqlKataRepository, IPortfolioEntryRepository { - public SqlKataPortfolioEntryRepository(SqlKataDatabase db) : base(db, "portfolio_entries") + public SqlKataPortfolioEntryRepository(SqlKataDatabase db) : base(db, SqlSchema.TablePortfolioEntries) { } @@ -22,9 +22,9 @@ public SqlKataPortfolioEntryRepository(SqlKataDatabase db) : base(db, "portfolio public 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..af770be 100644 --- a/Repository/SqlKataPortfolioRepository.cs +++ b/Repository/SqlKataPortfolioRepository.cs @@ -5,7 +5,7 @@ namespace Repository { public class SqlKataPortfolioRepository : SqlKataRepository, IPortfolioRepository { - public SqlKataPortfolioRepository(SqlKataDatabase db) : base(db, "portfolios") + public SqlKataPortfolioRepository(SqlKataDatabase db) : base(db, SqlSchema.TablePortfolios) { } From 6032800509060e4e02acb025ce6a40e7a99fc455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 21:41:43 +0200 Subject: [PATCH 13/71] Changed CryptoNameResolver.cs in such way that all "binange-peg" cryptocurrency entries are filtered out. --- CryptoStatsSource/CryptoNameResolver.cs | 4 ++-- Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CryptoStatsSource/CryptoNameResolver.cs b/CryptoStatsSource/CryptoNameResolver.cs index b1c7b9c..1e1cd40 100644 --- a/CryptoStatsSource/CryptoNameResolver.cs +++ b/CryptoStatsSource/CryptoNameResolver.cs @@ -44,8 +44,8 @@ public async Task Refresh() // fetch all cryptocurrencies and add them to the dictionary using the symbol as a key (await _cryptoStatsSource.GetAvailableCryptocurrencies()) - // workaround till Coingecko removes binance-peg-cardano entry - .Where(c => c.Symbol != "ada" && c.Id != "binance-peg-cardano").ToList() + // workaround till Coingecko removes binance-peg entries + .Where(c => !c.Id.Contains("binance-peg")).ToList() .ForEach(c => _nameToCryptocurrencyDictionary.TryAdd(c.Symbol, c)); } diff --git a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs index 8c42f57..35600fb 100644 --- a/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs +++ b/Tests/Integration/CryptoStatsSource/CryptoNameResolverTest.cs @@ -18,7 +18,7 @@ public ResolveNameTest() public async void SimpleThreeEntries() { Assert.Equal(new("bitcoin", "btc", "Bitcoin"), await _resolver.Resolve("btc")); - Assert.Equal(new ("cardano", "ada", "Cardano"), await _resolver.Resolve("cardano")); + 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")); From 9df16477a303d911fd748ce4e5d4565c9b8203e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 21:55:11 +0200 Subject: [PATCH 14/71] Added documentation to the Model project --- Model/Model.cs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) 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 From 3d2a14a60f4089c24e11ddba9cef5724a37a37b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 22:03:23 +0200 Subject: [PATCH 15/71] Removed a unused method in the IRepository interface --- Repository/IRepository.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Repository/IRepository.cs b/Repository/IRepository.cs index 74e6b7e..e98a98c 100644 --- a/Repository/IRepository.cs +++ b/Repository/IRepository.cs @@ -6,9 +6,6 @@ namespace Repository // TODO comments public interface IRepository { - // TODO remove from IRepository - public object ToRow(T entry); - public int Add(T entry); public T Get(int id); From 6d12b2b903fb299f85b6c37b773fb43196e0fd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Wed, 19 May 2021 22:20:59 +0200 Subject: [PATCH 16/71] Added documentation to the IRepository.cs file --- Repository/IRepository.cs | 61 +++++++++++++++++++++- Repository/SqlKataMarketOrderRepository.cs | 4 +- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Repository/IRepository.cs b/Repository/IRepository.cs index e98a98c..7adb7fd 100644 --- a/Repository/IRepository.cs +++ b/Repository/IRepository.cs @@ -3,34 +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 { + /// + /// 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 + /// A flag indicating whether the orders have been successfully 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); } } diff --git a/Repository/SqlKataMarketOrderRepository.cs b/Repository/SqlKataMarketOrderRepository.cs index 8690904..544687d 100644 --- a/Repository/SqlKataMarketOrderRepository.cs +++ b/Repository/SqlKataMarketOrderRepository.cs @@ -38,7 +38,7 @@ public override MarketOrder FromRow(dynamic d) => public List GetAllByPortfolioEntryId(int portfolioEntryId) => RowsToObjects(Db.Get().Query(tableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId).Get()); - public int DeletePortfolioEntryOrders(int portfolioEntryOrder) => - Db.Get().Query(tableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryOrder).Delete(); + public int DeletePortfolioEntryOrders(int portfolioEntryId) => + Db.Get().Query(tableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId).Delete(); } } \ No newline at end of file From df7a953cf30b07a2711593a3743213f1c8942059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 20 May 2021 22:15:14 +0200 Subject: [PATCH 17/71] Added documentation to IRepository SqlKata implementations --- Database/SqlSchema.cs | 8 +- Repository/SqlKataMarketOrderRepository.cs | 34 +++++-- Repository/SqlKataPortfolioEntryRepository.cs | 13 ++- Repository/SqlKataPortfolioRepository.cs | 10 +- Repository/SqlKataRepository.cs | 92 +++++++++---------- 5 files changed, 90 insertions(+), 67 deletions(-) diff --git a/Database/SqlSchema.cs b/Database/SqlSchema.cs index 4ff422e..821c86e 100644 --- a/Database/SqlSchema.cs +++ b/Database/SqlSchema.cs @@ -4,19 +4,21 @@ namespace Database { public class SqlSchema { + public const string TableIdPrimaryKey = "id"; + public const string TablePortfolios = "portfolios"; - public const string PortfoliosId = "id"; + 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 = "id"; + 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 = "id"; + public const string MarketOrdersId = TableIdPrimaryKey; public const string MarketOrdersFilledPrice = "filled_price"; public const string MarketOrdersFee = "fee"; public const string MarketOrdersSize = "size"; diff --git a/Repository/SqlKataMarketOrderRepository.cs b/Repository/SqlKataMarketOrderRepository.cs index 544687d..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, 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(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId).Get()); + // implement the method using the WHERE statement + RowsToObjects(Db.Get().Query(TableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId) + .Get()); public int DeletePortfolioEntryOrders(int portfolioEntryId) => - Db.Get().Query(tableName).Where(SqlSchema.MarketOrdersPortfolioEntryId, portfolioEntryId).Delete(); + // 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 2211996..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, SqlSchema.TablePortfolioEntries) { } - protected override int _getEntryId(PortfolioEntry entry) => entry.Id; + protected override int GetEntryId(PortfolioEntry entry) => entry.Id; - public override object ToRow(PortfolioEntry entry) => new + protected override object ToRow(PortfolioEntry entry) => new { symbol = entry.Symbol, portfolio_id = entry.PortfolioId }; - public override PortfolioEntry FromRow(dynamic d) => new((string) d.symbol, (int) d.portfolio_id, (int) d.id); + protected override PortfolioEntry FromRow(dynamic d) => new((string) d.symbol, (int) d.portfolio_id, (int) d.id); public List GetAllByPortfolioId(int portfolioId) => - RowsToObjects(Db.Get().Query(tableName).Where(SqlSchema.PortfolioEntriesPortfolioId, portfolioId).Get()); + RowsToObjects(Db.Get().Query(TableName).Where(SqlSchema.PortfolioEntriesPortfolioId, portfolioId).Get()); public int DeletePortfolioEntries(int portfolioId) => - Db.Get().Query(tableName).Where(SqlSchema.PortfolioEntriesPortfolioId, 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 af770be..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, 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..053e4cd 100644 --- a/Repository/SqlKataRepository.cs +++ b/Repository/SqlKataRepository.cs @@ -9,68 +9,66 @@ 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; - } - - 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(SqlSchema.TableIdPrimaryKey, 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(SqlSchema.TableIdPrimaryKey, 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(SqlSchema.TableIdPrimaryKey, 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 From 179cb882b03551910d31dca195c8ea60d337b5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 20 May 2021 22:23:25 +0200 Subject: [PATCH 18/71] Improved primary key column name referencing in the SqlKataRepository.cs --- Repository/SqlKataRepository.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Repository/SqlKataRepository.cs b/Repository/SqlKataRepository.cs index 053e4cd..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; @@ -44,21 +43,26 @@ public SqlKataRepository(SqlKataDatabase db, string tableName) /// ID of the given object protected abstract int GetEntryId(T entry); + /// + /// Returns the name of the primary key column + /// + protected string GetPrimaryKeyColumnName => SqlSchema.TableIdPrimaryKey; + // 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)); // 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(SqlSchema.TableIdPrimaryKey, GetEntryId(entry)).Update(ToRow(entry)) > 0; + 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(SqlSchema.TableIdPrimaryKey, GetEntryId(entry)).Delete() > 0; + public bool Delete(T entry) => Db.Get().Query(TableName).Where(GetPrimaryKeyColumnName, GetEntryId(entry)).Delete() > 0; public T Get(int id) { // find a table rows based on the given ID - var result = Db.Get().Query(TableName).Where(SqlSchema.TableIdPrimaryKey, id).FirstOrDefault(); + 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); From 626cb56b020e7772f380497cf3bd6413925b27d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 20 May 2021 22:35:46 +0200 Subject: [PATCH 19/71] Added documentation to the MarketOrderService.cs --- Repository/IRepository.cs | 2 +- Services/Services/MarketOrderService.cs | 44 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Repository/IRepository.cs b/Repository/IRepository.cs index 7adb7fd..86b0142 100644 --- a/Repository/IRepository.cs +++ b/Repository/IRepository.cs @@ -60,7 +60,7 @@ public interface IMarketOrderRepository : IRepository /// Deletes all market orders of the portfolio entry given by an ID /// /// ID of the entry whose orders should be deleted - /// A flag indicating whether the orders have been successfully deleted + /// Number of orders deleted public int DeletePortfolioEntryOrders(int portfolioEntryId); } diff --git a/Services/Services/MarketOrderService.cs b/Services/Services/MarketOrderService.cs index fb4b7b2..f036d63 100644 --- a/Services/Services/MarketOrderService.cs +++ b/Services/Services/MarketOrderService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using Model; using Repository; @@ -8,23 +7,59 @@ namespace Services { 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. An 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 +69,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}; } From eb31a6c04652bcce81e45275801813342b7f328b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 20 May 2021 22:49:13 +0200 Subject: [PATCH 20/71] Added documentation to the PortfolioEntryService --- Services/Services/MarketOrderService.cs | 2 +- Services/Services/PortfolioEntryService.cs | 57 ++++++++++++++++--- .../Unit/Service/PortfolioEntryServiceTest.cs | 2 +- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Services/Services/MarketOrderService.cs b/Services/Services/MarketOrderService.cs index f036d63..32e5e42 100644 --- a/Services/Services/MarketOrderService.cs +++ b/Services/Services/MarketOrderService.cs @@ -28,7 +28,7 @@ MarketOrder CreateMarketOrder(decimal filledPrice, decimal fee, decimal size, bool DeleteMarketOrder(MarketOrder order); /// - /// Updates the given order in the repository. An order with the same ID in the repository is replaced with the one + /// Updates the given order in the repository. The order with the same ID in the repository is replaced with the one /// passed. /// /// Updated order diff --git a/Services/Services/PortfolioEntryService.cs b/Services/Services/PortfolioEntryService.cs index ee3f219..e156935 100644 --- a/Services/Services/PortfolioEntryService.cs +++ b/Services/Services/PortfolioEntryService.cs @@ -8,23 +8,57 @@ namespace Services { 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 +66,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 +92,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/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); From 3d4206bc6a2f0f77b8b45f2814ae9e6728e7f642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 20 May 2021 23:02:15 +0200 Subject: [PATCH 21/71] Added documentation summaries to MarketOrderService and PortfolioEntryService Added documentation to the PortfolioService --- Services/Services/MarketOrderService.cs | 3 + Services/Services/PortfolioEntryService.cs | 3 + Services/Services/PortfolioService.cs | 74 ++++++++++++++++------ 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/Services/Services/MarketOrderService.cs b/Services/Services/MarketOrderService.cs index 32e5e42..4bf5ee8 100644 --- a/Services/Services/MarketOrderService.cs +++ b/Services/Services/MarketOrderService.cs @@ -5,6 +5,9 @@ namespace Services { + /// + /// A service that is responsible for managing market orders and storing them to a persistent repository. + /// public interface IMarketOrderService { /// diff --git a/Services/Services/PortfolioEntryService.cs b/Services/Services/PortfolioEntryService.cs index e156935..4c24b0f 100644 --- a/Services/Services/PortfolioEntryService.cs +++ b/Services/Services/PortfolioEntryService.cs @@ -6,6 +6,9 @@ namespace Services { + /// + /// A service that is responsible for managing portfolio entries and storing them to a persistent repository. + /// public interface IPortfolioEntryService { /// 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 From 5598a544a6051b0d381857ad7a1d35465d31b0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 20 May 2021 23:27:48 +0200 Subject: [PATCH 22/71] Added documentation to the Summary record --- Services/Services/SummaryService.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Services/Services/SummaryService.cs b/Services/Services/SummaryService.cs index ef2ea8b..66fec53 100644 --- a/Services/Services/SummaryService.cs +++ b/Services/Services/SummaryService.cs @@ -6,8 +6,25 @@ namespace Services { + /// + /// A service that is responsible for computing 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); public Summary GetMarketOrderSummary(MarketOrder order, decimal assetPrice); @@ -97,6 +114,5 @@ public ISummaryService.Summary GetPortfolioEntrySummary(List portfo public ISummaryService.Summary GetPortfolioSummary(List portfolioEntrySummaries) => GetAverageOfSummaries(portfolioEntrySummaries); - } } \ No newline at end of file From 32e85bff4f90e9bf1f43cf266ec5f38d89e65c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Fri, 21 May 2021 07:44:38 +0200 Subject: [PATCH 23/71] Added documentation to the rest of the SummaryService --- Services/Services/SummaryService.cs | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Services/Services/SummaryService.cs b/Services/Services/SummaryService.cs index 66fec53..926f907 100644 --- a/Services/Services/SummaryService.cs +++ b/Services/Services/SummaryService.cs @@ -7,7 +7,7 @@ namespace Services { /// - /// A service that is responsible for computing summaries (total profit, cost,...) of orders, portfolio entries and + /// A service that is responsible for calculating summaries (total profit, cost,...) of orders, portfolio entries and /// even portfolios. /// public interface ISummaryService @@ -27,10 +27,27 @@ public interface ISummaryService /// 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); } @@ -38,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); } @@ -56,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) { @@ -103,8 +128,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); @@ -112,6 +139,7 @@ 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); } From def3587649674bf1b50dd0f76c5f262a3eafcd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Fri, 21 May 2021 07:48:18 +0200 Subject: [PATCH 24/71] Added documentation to CurrencyUtils.cs --- Utils/CurrencyUtils.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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); } From d0df169739ea911d9ab5f5be005001a3d1852e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Fri, 21 May 2021 07:50:24 +0200 Subject: [PATCH 25/71] Added documentation to CurrencyUtils.cs --- Utils/DecimalUtils.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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); From d8a7423c4ee84ca5d4f357a8dfdbecb92d1b2b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Fri, 21 May 2021 07:57:59 +0200 Subject: [PATCH 26/71] Added documentation to EnumUtils.cs --- Utils/EnumUtils.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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(); } From b8b0b063b877df6a835a964c8576cbe1ddd96d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Fri, 21 May 2021 13:10:59 +0200 Subject: [PATCH 27/71] Minor TeX documentation improvements. --- doc/doc.tex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index abe92a4..2183b71 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -14,6 +14,7 @@ \usepackage{tabularx} \usepackage[final]{pdfpages} \usepackage{syntax} +\usepackage{listings} \definecolor{mauve}{rgb}{0.58,0,0.82} @@ -193,10 +194,11 @@ \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(tableName).Where("portfolio_entry_id", portfolioEntryId).Get() +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 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. +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) => @@ -206,7 +208,7 @@ \subsubsection{Generování SQL dotazů} 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 pro výpočet výkonu jednotlivých entit} -Aby bylo možné vypočítat výkon (výdělek či ztráta) jednotlivých entit (portfólio, položka portfólia či uskutečněný obchod), byla vytvořeno rozhraní \texttt{ISummaryService} a jeho implementace \texttt{SummaryServiceImpl}. +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}. \section{Framework pro grafické rozhraní} \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} From b8fa08b472689532640e384b656eeeb900d178a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Fri, 21 May 2021 13:17:41 +0200 Subject: [PATCH 28/71] Formatting --- doc/doc.tex | 158 +++++++++++++++++++++++++++------------------------- 1 file changed, 83 insertions(+), 75 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index 2183b71..9520553 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -69,21 +69,24 @@ \let\oldsection\section -\renewcommand\section{\clearpage\oldsection} +\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} @@ -115,43 +118,45 @@ \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 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í. -\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{Datový zdroj s aktuálním kurzem kryptoměn} -\begin{lstlisting} + 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í. + + \begin{lstlisting} \$ curl -X GET "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd" -H "accept: application/json" { @@ -159,71 +164,74 @@ \subsection{Webový zdroj CoinGecko} "usd": 56224 } } -\end{lstlisting} + \end{lstlisting} -\subsection{Výběr databáze pro implementaci datové vrstvy aplikace} + \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. + 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í. + 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í. + \section{Popis architektury vytvořené aplikace} -\section{Popis architektury vytvořené aplikace} -\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. + \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ě. + 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} + \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} + \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. + \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}] + \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} + \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}}. + 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}] + \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} + \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 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}. + -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 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}. + \section{Framework pro grafické rozhraní} + \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} -\section{Framework pro grafické rozhraní} -\textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} -\section{Oveření kvality vytvořeného software} + \section{Oveř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}}. + 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). + 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:}. + 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ískvání informací o aktuálním stavu trhu s kryptoměnami. + Vytvořené integrační testy ověřují funkčnost datové vrstvy a datového zdroje pro získvá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] + \begin{lstlisting}[caption={Struktura projektu \texttt{Tests} obsahující integrační a jednotkové testy}, captionpos=b] |-- Integration | |-- CryptoStatsSource | | |-- CryptoNameResolverTest.cs @@ -238,11 +246,11 @@ \section{Oveření kvality vytvořeného software} |-- PortfolioEntryServiceTest.cs |-- PortfolioServiceTest.cs `-- SummaryServiceTest.cs -\end{lstlisting} + \end{lstlisting} -\printbibliography + \printbibliography \end{document} From 5bccd693fc1562488edbfb85639c23c93438b39b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 22 May 2021 09:31:35 +0200 Subject: [PATCH 29/71] Added documentation of the SummaryService --- doc/doc.tex | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index 9520553..b8ac54e 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -68,11 +68,7 @@ } -\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 @@ -215,8 +211,27 @@ \section{\clearpage\oldsection} \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. + \section{Framework pro grafické rozhraní} \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} From 650b94709a44bfc328309d4fac5724809514f2e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 22 May 2021 13:05:28 +0200 Subject: [PATCH 30/71] Added documentation of the PortfolioService, PortfolioEntryService and MarketOrderService. --- doc/doc.tex | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/doc.tex b/doc/doc.tex index b8ac54e..ce06ecd 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -1,3 +1,4 @@ +%! suppress = Unicode \documentclass[12pt, a4paper]{article} \usepackage[czech,shorthands=off]{babel} @@ -207,6 +208,19 @@ \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žá 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{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}. From 140b6f62a90d67c6e0717221d4bbbbc446992c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 22 May 2021 16:24:57 +0200 Subject: [PATCH 31/71] Documentation of the ICryptoStatsSource interface. --- doc/doc.tex | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index ce06ecd..91f9930 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -146,11 +146,13 @@ \end{figure} - \section{Datový zdroj s aktuálním kurzem kryptoměn} + \section{Analýza} + + \subsection{Datový zdroj s aktuálním kurzem kryptoměn} 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} + \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} @@ -169,7 +171,6 @@ 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í. - \section{Popis architektury vytvořené aplikace} \subsection{Databázová vrstva} @@ -222,6 +223,20 @@ \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}. From baa5d90f9646c67ef0522f6c5a6625ff264f64e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sat, 22 May 2021 16:34:48 +0200 Subject: [PATCH 32/71] Added documentation of the Utils project. --- doc/doc.tex | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/doc.tex b/doc/doc.tex index 91f9930..d115ecd 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -260,6 +260,16 @@ \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. + + \subsubsection{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} \section{Framework pro grafické rozhraní} \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} From 922f828ef0c91bd2d7ffbf15ba99314daf9d273f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 09:32:28 +0200 Subject: [PATCH 33/71] Added the user guide. --- doc/doc.tex | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/doc/doc.tex b/doc/doc.tex index d115ecd..73913dc 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -301,10 +301,122 @@ |-- PortfolioServiceTest.cs `-- SummaryServiceTest.cs \end{lstlisting} + + \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í. + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{example-image-a}} + \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. - \printbibliography + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{example-image-a}} + \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ě se budou prováděny transakce. + Nakonec se ve spodní části nachází tlačítka pro odeslání formuláře či jeho obnovení. + + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{example-image-a}} + \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. + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{example-image-a}} + \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=\textwidth]{example-image-a}} + \caption{Okno s detailem vybrané transakce} + \label{fig:transaction-detail} + \end{figure} + + \subsection{Formulář pro vytvoření či editaci transakce} + K vytvoření či editaci transakce slouží jednoduchý formmulář, 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]{example-image-a}} + \caption{Formulář pro editaci či vytvoření ransakce} + \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á. + + \begin{figure}[!ht] + \centering + {\includegraphics[width=\textwidth]{example-image-a}} + \caption{Obrazovka správy položek portólia} + \label{fig:portfolio-entry-mngmnt} + \end{figure} + + \printbibliography \end{document} From aecd52f0750d8eca1b8736a14ef1038f4a461799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 10:33:23 +0200 Subject: [PATCH 34/71] Added some SummaryService.cs comments --- Services/Services/SummaryService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Services/Services/SummaryService.cs b/Services/Services/SummaryService.cs index 926f907..70570bb 100644 --- a/Services/Services/SummaryService.cs +++ b/Services/Services/SummaryService.cs @@ -111,12 +111,15 @@ public ISummaryService.Summary GetPortfolioEntrySummary(List portfo 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; } From 76a0cf5eabedc22a1ae712bc7878e94bdcfef6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 10:43:04 +0200 Subject: [PATCH 35/71] Removed escaping of quotes on NewPortfolioEntry.razor page --- WebFrontend/Pages/NewPortfolioEntry.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebFrontend/Pages/NewPortfolioEntry.razor b/WebFrontend/Pages/NewPortfolioEntry.razor index 2832448..d6905da 100644 --- a/WebFrontend/Pages/NewPortfolioEntry.razor +++ b/WebFrontend/Pages/NewPortfolioEntry.razor @@ -71,7 +71,7 @@ } else { - No cryptocurrencies match the symbol \"@CryptocurrencyFilter\" + No cryptocurrencies match the symbol "@CryptocurrencyFilter" } }
From e81bb0b49a22a2e275260a499ffe0b0a1fdf79a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 10:55:05 +0200 Subject: [PATCH 36/71] Documentation typo --- doc/doc.tex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index 73913dc..3bc5a95 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -334,7 +334,7 @@ \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ě se budou prováděny transakce. + 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í. \begin{figure}[!ht] @@ -400,13 +400,14 @@ \begin{figure}[!ht] \centering {\includegraphics[width=\textwidth]{example-image-a}} - \caption{Formulář pro editaci či vytvoření ransakce} + \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. + 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á. From dc56bf80f6d35cffc14f08e32293016322658e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 12:03:13 +0200 Subject: [PATCH 37/71] Layout improvements --- WebFrontend/Pages/Counter.razor | 16 ----- WebFrontend/Pages/EditMarketOrder.razor | 10 ++-- WebFrontend/Pages/EditPortfolio.razor | 4 +- WebFrontend/Pages/FetchData.razor | 71 ----------------------- WebFrontend/Pages/Index.razor | 6 +- WebFrontend/Pages/NewMarketOrder.razor | 8 ++- WebFrontend/Pages/NewPortfolio.razor | 5 +- WebFrontend/Pages/NewPortfolioEntry.razor | 4 +- WebFrontend/Pages/_Host.cshtml | 2 +- WebFrontend/Shared/MainLayout.razor | 41 +------------ WebFrontend/Shared/NavMenu.razor | 6 -- WebFrontend/Shared/NavMenu.razor.css | 62 -------------------- 12 files changed, 27 insertions(+), 208 deletions(-) delete mode 100644 WebFrontend/Pages/Counter.razor delete mode 100644 WebFrontend/Pages/FetchData.razor delete mode 100644 WebFrontend/Shared/NavMenu.razor delete mode 100644 WebFrontend/Shared/NavMenu.razor.css 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..177bfa1 100644 --- a/WebFrontend/Pages/EditMarketOrder.razor +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -17,15 +17,17 @@
-
+
+
Back

Edit a market order

- +
+
@@ -34,7 +36,7 @@ { [Parameter] public int OrderId { get; set; } - + protected EntryForm.NewOrderModel InitialOrderModel; protected Portfolio ActivePortfolio; protected PortfolioEntry ActiveEntry; @@ -42,7 +44,7 @@ protected override void OnInitialized() { - ActiveMarketOrder = MarketOrderService.GetMarketOrder(OrderId); + ActiveMarketOrder = MarketOrderService.GetMarketOrder(OrderId); ActiveEntry = PortfolioEntrySerivce.GetPortfolioEntry(ActiveMarketOrder.PortfolioEntryId); ActivePortfolio = PortfolioService.GetPortfolio(ActiveEntry.PortfolioId); InitialOrderModel = new(); diff --git a/WebFrontend/Pages/EditPortfolio.razor b/WebFrontend/Pages/EditPortfolio.razor index 2a289d8..960d381 100644 --- a/WebFrontend/Pages/EditPortfolio.razor +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -25,7 +25,8 @@
-
+
+
Back @@ -40,6 +41,7 @@
+
diff --git a/WebFrontend/Pages/FetchData.razor b/WebFrontend/Pages/FetchData.razor deleted file mode 100644 index 6eb5731..0000000 --- a/WebFrontend/Pages/FetchData.razor +++ /dev/null @@ -1,71 +0,0 @@ -@page "/fetchdata" - -@using ServerSideBlazor.Data -@using CryptoStatsSource.model -@using CryptoStatsSource -@inject WeatherForecastService ForecastService -@inject ICryptoStatsSource CryptoStatsService; - -

Weather forecast

- -

This component demonstrates fetching data from a service.

- -@if (forecasts == null) -{ - -} -else -{ - - - Date - Temp. (C) - Temp. (F) - Summary - - - @context.Date.ToShortDateString() - @context.TemperatureC - @context.TemperatureF - @context.Summary - - -} - -@if (entries == null) -{ - -} -else -{ - - - Symbol - Name - Current Price ($) - Market Cap ($) - Price Change Last 24h ($) - Price Change Last 24h (%) - - - @context.Symbol - @context.Name - @context.CurrentPrice - @context.MarketCap - @context.PriceChange24H - @context.PriceChangePercentage24H - - -} - -@code { - WeatherForecast[] forecasts; - MarketEntry[] entries; - - protected override async Task OnInitializedAsync() - { - forecasts = await ForecastService.GetForecastAsync(DateTime.Now); - entries = (await CryptoStatsService.GetMarketEntries("usd", "bitcoin", "litecoin", "cardano")).ToArray(); - - } -} diff --git a/WebFrontend/Pages/Index.razor b/WebFrontend/Pages/Index.razor index f71c5ee..071b820 100644 --- a/WebFrontend/Pages/Index.razor +++ b/WebFrontend/Pages/Index.razor @@ -39,8 +39,8 @@
-
-
+
+
Portfolios @if (PortfoliosWithEntries == null) { @@ -93,7 +93,7 @@ } }
-
+
diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor index ba6da76..64e14b4 100644 --- a/WebFrontend/Pages/NewMarketOrder.razor +++ b/WebFrontend/Pages/NewMarketOrder.razor @@ -17,15 +17,17 @@
-
+
+
Back

Create a new market order

- +
+
@@ -34,7 +36,7 @@ { [Parameter] public int EntryId { get; set; } - + protected Portfolio ActivePortfolio = new("", "", Currency.Usd); protected PortfolioEntry ActiveEntry = new("btc", 1); diff --git a/WebFrontend/Pages/NewPortfolio.razor b/WebFrontend/Pages/NewPortfolio.razor index e2b68df..e3a7a06 100644 --- a/WebFrontend/Pages/NewPortfolio.razor +++ b/WebFrontend/Pages/NewPortfolio.razor @@ -16,7 +16,9 @@
-
+
+
+ Back @@ -29,6 +31,7 @@
+
diff --git a/WebFrontend/Pages/NewPortfolioEntry.razor b/WebFrontend/Pages/NewPortfolioEntry.razor index d6905da..04a949e 100644 --- a/WebFrontend/Pages/NewPortfolioEntry.razor +++ b/WebFrontend/Pages/NewPortfolioEntry.razor @@ -30,7 +30,8 @@
-
+
+
Back to portfolioManage entries of @Portfolio.Name @if (AvailableCryptocurrenciesWithUsage == null) @@ -75,6 +76,7 @@ } }
+
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..df1d8f7 100644 --- a/WebFrontend/Shared/MainLayout.razor +++ b/WebFrontend/Shared/MainLayout.razor @@ -2,32 +2,13 @@ - - - Material Design Blazor Template (Server-side) - - - + + Crypto Portfolio Tracker - - - - - - - - - - - - - - - @@ -37,21 +18,3 @@ - -@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; - } -} From 3c41557af0a73e796fdbc8b1209ead47f5ca73ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 12:07:56 +0200 Subject: [PATCH 38/71] Removed unused Weather Forecast related files --- WebFrontend/Data/WeatherForecast.cs | 15 ---------- WebFrontend/Data/WeatherForecastService.cs | 32 ---------------------- WebFrontend/Startup.cs | 3 -- 3 files changed, 50 deletions(-) delete mode 100644 WebFrontend/Data/WeatherForecast.cs delete mode 100644 WebFrontend/Data/WeatherForecastService.cs 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/Startup.cs b/WebFrontend/Startup.cs index e971d8a..cb39aa2 100644 --- a/WebFrontend/Startup.cs +++ b/WebFrontend/Startup.cs @@ -9,9 +9,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; @@ -33,7 +31,6 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddRazorPages(); services.AddServerSideBlazor(); - services.AddSingleton(); services.AddMatBlazor(); services.AddMatToaster(config => From 05a0e33a541ce8b139e3ad3992227e5b97b5479d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 13:39:30 +0200 Subject: [PATCH 39/71] CHanged application theme --- WebFrontend/App.razor | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/WebFrontend/App.razor b/WebFrontend/App.razor index 3b38d9e..9db30c7 100644 --- a/WebFrontend/App.razor +++ b/WebFrontend/App.razor @@ -1,8 +1,10 @@ - - - + + + + + @@ -10,3 +12,12 @@ + + +@code +{ + MatTheme appTheme = new() + { + Primary = MatThemeColors.BlueGrey._500.Value + }; +} From 8048a86809e8d19e9967b331232cb21058577803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 14:30:00 +0200 Subject: [PATCH 40/71] Minor improvements to the application's GUI --- WebFrontend/Pages/EditPortfolio.razor | 2 +- WebFrontend/Pages/NewPortfolio.razor | 2 +- WebFrontend/Pages/PortfolioEntryDetail.razor | 2 +- WebFrontend/Shared/MainLayout.razor | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/WebFrontend/Pages/EditPortfolio.razor b/WebFrontend/Pages/EditPortfolio.razor index 960d381..86f506d 100644 --- a/WebFrontend/Pages/EditPortfolio.razor +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -27,7 +27,7 @@
- Back + Back

Edit portfolio

diff --git a/WebFrontend/Pages/NewPortfolio.razor b/WebFrontend/Pages/NewPortfolio.razor index e3a7a06..daa9d6b 100644 --- a/WebFrontend/Pages/NewPortfolio.razor +++ b/WebFrontend/Pages/NewPortfolio.razor @@ -19,7 +19,7 @@
- Back + Back

New portfolio

diff --git a/WebFrontend/Pages/PortfolioEntryDetail.razor b/WebFrontend/Pages/PortfolioEntryDetail.razor index c38840b..c862b9c 100644 --- a/WebFrontend/Pages/PortfolioEntryDetail.razor +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -51,7 +51,7 @@
- Back + BackPortfolio Entry @if(ActivePortfolioEntry != null) diff --git a/WebFrontend/Shared/MainLayout.razor b/WebFrontend/Shared/MainLayout.razor index df1d8f7..e572797 100644 --- a/WebFrontend/Shared/MainLayout.razor +++ b/WebFrontend/Shared/MainLayout.razor @@ -8,11 +8,11 @@ - + -
+
@Body
From a524e3974a9a394ffefcdb1f11fe77ca75989a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 14:34:10 +0200 Subject: [PATCH 41/71] Added application screenshots to the user guide --- doc/doc.tex | 17 +++++++++-------- doc/img/cpt-screenshots/order-form.png | Bin 0 -> 34383 bytes doc/img/cpt-screenshots/portfolio-detail.png | Bin 0 -> 56288 bytes .../cpt-screenshots/portfolio-entry-detail.png | Bin 0 -> 71987 bytes .../cpt-screenshots/portfolio-entry-mngmt.png | Bin 0 -> 31351 bytes doc/img/cpt-screenshots/portfolio-form.png | Bin 0 -> 22904 bytes doc/img/cpt-screenshots/portfolio-list.png | Bin 0 -> 37044 bytes .../cpt-screenshots/portfolio-order-detail.png | Bin 0 -> 39172 bytes 8 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 doc/img/cpt-screenshots/order-form.png create mode 100644 doc/img/cpt-screenshots/portfolio-detail.png create mode 100644 doc/img/cpt-screenshots/portfolio-entry-detail.png create mode 100644 doc/img/cpt-screenshots/portfolio-entry-mngmt.png create mode 100644 doc/img/cpt-screenshots/portfolio-form.png create mode 100644 doc/img/cpt-screenshots/portfolio-list.png create mode 100644 doc/img/cpt-screenshots/portfolio-order-detail.png diff --git a/doc/doc.tex b/doc/doc.tex index 3bc5a95..a631b2e 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -311,7 +311,7 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\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} @@ -325,7 +325,7 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\includegraphics[width=0.7\textwidth]{img/cpt-screenshots/portfolio-detail.png}} \caption{Obrazovka zobrazující detail portfólia} \label{fig:portfolio-detail} \end{figure} @@ -339,7 +339,7 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\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} @@ -369,7 +369,7 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\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} @@ -379,11 +379,12 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\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ý formmulář, do kterého je třeba zadat následující údaje: @@ -399,7 +400,7 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\includegraphics[width=\textwidth]{img/cpt-screenshots/order-form.png}} \caption{Formulář pro editaci či vytvoření transakce} \label{fig:transaction-form} \end{figure} @@ -413,7 +414,7 @@ \begin{figure}[!ht] \centering - {\includegraphics[width=\textwidth]{example-image-a}} + {\includegraphics[width=\textwidth]{img/cpt-screenshots/portfolio-entry-mngmt.png}} \caption{Obrazovka správy položek portólia} \label{fig:portfolio-entry-mngmnt} \end{figure} diff --git a/doc/img/cpt-screenshots/order-form.png b/doc/img/cpt-screenshots/order-form.png new file mode 100644 index 0000000000000000000000000000000000000000..e641cf637cb177f1054ea91840937678991641df GIT binary patch literal 34383 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 literal 0 HcmV?d00001 diff --git a/doc/img/cpt-screenshots/portfolio-detail.png b/doc/img/cpt-screenshots/portfolio-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..6d23a7c826b5e08e06fc01e979ba15fec156ed65 GIT binary patch literal 56288 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

lb;EeY=$J-rNXCzj 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 WtXObD`&c^Y-W*R?KbLh*2~7avCWdJM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e62899770e4e9ac968f48d769dda05796b780b6e GIT binary patch literal 31351 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

KL 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 UNS_jaM}gFwnPxMhrmgh-7qgm!+W-In literal 0 HcmV?d00001 diff --git a/doc/img/cpt-screenshots/portfolio-form.png b/doc/img/cpt-screenshots/portfolio-form.png new file mode 100644 index 0000000000000000000000000000000000000000..b83f04a3f3d12fee946f32d753be29561c680345 GIT binary patch literal 22904 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 literal 0 HcmV?d00001 diff --git a/doc/img/cpt-screenshots/portfolio-list.png b/doc/img/cpt-screenshots/portfolio-list.png new file mode 100644 index 0000000000000000000000000000000000000000..ee94315798825314fca5256ed8f4981a05c57cd4 GIT binary patch literal 37044 zcmeFZ2UL`2*ETvxltcwW6hUh23Iai;*F=FRQWOx8rl9lz>7603uZmPFAYG6mHHd(e z0gb>YRXWl}LCOrxff)v-o_(Wv|MxrpT4$Yqt-pNdtS>7G!^|_!bKm>k<=T5+n>(h) z2K-yawqh_C{?osoI*-9@HpO6gExz9Z?_@akzs6vG#+*L&t9ek`{8(_Z#m)4YPrULk z#p4*F`wq%0#&i$F>RUfB?pgIaxY~RAkc)E<_R!C6u};r-E45%ehwg0l{7$4v(sJi1 zA=6X8p8IwC;;-kPW<~3*mPB5{t?=324lU|D;+7L_y2RAXbRMqDsC^imTt?4Gr7l|j zbbtS8^zYM_1L@(IZ@;`KangPC?ftav1>fF%_<+aa+e@EgS!ce!Ja4?keB%cDPq)5{ zl==2jZMW%xuP=`r;w}7or~Ok`|Bv02$56$9HjZ{b7Vr^sHQJFJtH&Y78sE!Vd)tyV7XCE@oac8&B0}?B&*1nRA~6<`e2%NXy1LQgA4FNX}0M z%9f^XOy;VLV|~6WEG~>6krp2$5r$KwB0_vLIirS@HD871U`yiAldiG8$l><}Q!x{k zgX2BV%KG2m&iviJNQbq)I69)G%2}troFngb>SX+Uu>Qn8&w89+odh9qOP_GjVBVk( zd@c}--8kEdBhu}0O2Tt7x1OgCyZ$`)c3>B4d!syQ z=#S^ub>(dTRL#KA;$A#EPP~)QDk6+uy%v+y^dZT9(6y{THjrqNFjZxc*@_=4PMFTG z4)vSZwh$pG>ZIwF@#0(Q~hw!O8Ca&ZYw{wNm=b4yR@3%{g-N-uxY-{wDgEkIall5rkTmC zGt*elTAW|sz(8)bxZ>&u*aElkmM?bUEe7k zWY!(YSmAPf^QBDOkE3&F%tZ@$_J-Wsxc@?lZIZyDHwM>mry7?cUzdl`Qm6qZrNw$l zgz@`TG@i=9#{0=9*feUuQit5!i^0MgUh?xtMZF`oA(N6{_g(#HL)4O)$gKP?@_ONZ zmNY_2rOjUPvR3_Kl)?$=_V=pSIfVE8xFFc%*wrA~W?6Elwl+#bI`hYHhMDFLD)=z++)~q$SMBkzr4q6QA-A8IbC;s7n>jhcKb1{-J z!$){pZCx2)JB_{M8+Nmdh;#udR#2d^D}1Fny5GHbq{Pnp9`llL#v5Put+<+MYkbw- z6;b(G&x@KXt@aL|dZx}z7xYe@@;5TFx!!R%$a{LE z+>?DPD(dALV!j$bF!NTJ6*vX}#E?*>d~fSz~G6&&L&-?(#_|tj8ptGQwi(SuJ%7%QO2<->>TW zsy1R;q+GvO$((`WgI-MLp1_tb=O!K23cM-#byD)Rr5hIX(#5oPh4Mppo&9Mfv7WZP z299wKA(OKjN}}Z)!b+)Z<9l^L_ChYN`AM*@U2#z$3+|VhItNm#MNPFmzI1QtOIpLO z#+D9A)3U~;a%zD4fRfZ+>(DTM`$k5&{9g5|x{{19JI+}>Vmt3Dca>n{cHgkG=IdMG z8{fGUb}@xnuT)t{T5_^>TZj}ZC)vEYxs|UE=^MOVLudp6taW6~_lnr4&Q@J~;i0z} zZdq0o#o;%uyQ`9wp>)}_Uc-51?&ZhHYm|yu1qbo<_HE}ZlV~17{{H^ngjq`5qM2$| zqCD&6EN8>Tm~Z_5icto|N&VsprA6$szj!J?T|cjL9toW^wu{o62yj=ZxRh%Q5`)OX%Vwm8OrO`Yq@`M%*J3)-#m)Of;

YL2Is5YzcAtO;$PEaWBccn1xo|9wfAfx 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..da2bba823b7cf3aa1f9c16eb48f5e1ed649a94ef GIT binary patch literal 39172 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 DZ6b9K literal 0 HcmV?d00001 From 2bd3f1817f5b40efee2f3a6dd2f39a83853871a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 16:52:44 +0200 Subject: [PATCH 42/71] Added GUI framework analysis. --- doc/doc.tex | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/doc/doc.tex b/doc/doc.tex index a631b2e..a16fe8e 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -170,6 +170,33 @@ 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 náráží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} @@ -261,7 +288,7 @@ \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. - \subsubsection{Projekt \texttt{Utils}} + \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ří: From 6d9769f11cd65ab978ebdbfb099a81be1f93d0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 17:15:35 +0200 Subject: [PATCH 43/71] Added initial progress of the WebFrontend project description. Deleted UnitTest1.cs that contained no tests and was practically useless. Renamed NewPortfolioEntry.razor to PortfolioEntryManagement.razor. --- Services/UnitTest1.cs | 14 --------- ...y.razor => PortfolioEntryManagement.razor} | 0 doc/doc.tex | 29 +++++++++++++++++-- 3 files changed, 26 insertions(+), 17 deletions(-) delete mode 100644 Services/UnitTest1.cs rename WebFrontend/Pages/{NewPortfolioEntry.razor => PortfolioEntryManagement.razor} (100%) 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/WebFrontend/Pages/NewPortfolioEntry.razor b/WebFrontend/Pages/PortfolioEntryManagement.razor similarity index 100% rename from WebFrontend/Pages/NewPortfolioEntry.razor rename to WebFrontend/Pages/PortfolioEntryManagement.razor diff --git a/doc/doc.tex b/doc/doc.tex index a16fe8e..b49170a 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -298,9 +298,29 @@ \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} - \section{Framework pro grafické rozhraní} - \textit{Frontend realizovaný pomocí Blazor frameworku, zabalený do Electron wrapperu} - + \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. + + 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. + + \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}. \section{Oveření kvality vytvořeného software} @@ -329,6 +349,9 @@ `-- SummaryServiceTest.cs \end{lstlisting} + \section{Závěr} + \textbf{TODO} + \section{Uživatelská příručka} \subsection{Úvodní obrazovka} From 9f5d1b2fa659957cf7f587b59f9aecf19c0a3c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 17:25:33 +0200 Subject: [PATCH 44/71] Added description of Shared folder contents --- .../Shared/{EntryForm.razor => OrderForm.razor} | 0 doc/doc.tex | 14 +++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) rename WebFrontend/Shared/{EntryForm.razor => OrderForm.razor} (100%) diff --git a/WebFrontend/Shared/EntryForm.razor b/WebFrontend/Shared/OrderForm.razor similarity index 100% rename from WebFrontend/Shared/EntryForm.razor rename to WebFrontend/Shared/OrderForm.razor diff --git a/doc/doc.tex b/doc/doc.tex index b49170a..3c9ec26 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -320,7 +320,19 @@ \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}. + \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ýkonosti 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{Oveření kvality vytvořeného software} From d319f7ca07fbed707bfbff02e471ba36a158e64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 20:24:50 +0200 Subject: [PATCH 45/71] Added summary section content --- doc/doc.tex | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/doc/doc.tex b/doc/doc.tex index 3c9ec26..d547de6 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -362,7 +362,25 @@ \end{lstlisting} \section{Závěr} - \textbf{TODO} + 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. \section{Uživatelská příručka} From 1f714c04164cab851fa9f6ee502befa8e58156d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 20:59:17 +0200 Subject: [PATCH 46/71] Added few tests to increase the test coverage --- Database/SqlKataDatabase.cs | 2 - .../Integration/Repository/MarketOrderTest.cs | 57 ++++++++++++++++++- .../Repository/PortfolioEntryTest.cs | 48 +++++++++++++++- Tests/Unit/Service/MarketOrderServiceTest.cs | 15 +++++ Tests/Unit/Service/SummaryServiceTest.cs | 48 ++++++++++++++++ 5 files changed, 164 insertions(+), 6 deletions(-) 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/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/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 From 63f6dd94281ed6b5fa3b592c427ae9af6f0a79a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 21:18:30 +0200 Subject: [PATCH 47/71] Added developer's diary --- doc/doc.tex | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/doc.tex b/doc/doc.tex index d547de6..482cf13 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -382,6 +382,22 @@ Dalším rozšířením by mohlo být pokrytí uživatelského rozhraní automatickými testy, například pomocí nástroje Robot framework. + \subsection{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 74 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{4.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} From f0fac50548514b9cb048688795f032f6db050128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 21:46:45 +0200 Subject: [PATCH 48/71] Fixed the EntryForm component usage --- WebFrontend/App.razor | 1 + WebFrontend/Pages/EditMarketOrder.razor | 6 +++--- WebFrontend/Pages/NewMarketOrder.razor | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/WebFrontend/App.razor b/WebFrontend/App.razor index 9db30c7..4395f0d 100644 --- a/WebFrontend/App.razor +++ b/WebFrontend/App.razor @@ -16,6 +16,7 @@ @code { + // to be used as application's theme MatTheme appTheme = new() { Primary = MatThemeColors.BlueGrey._500.Value diff --git a/WebFrontend/Pages/EditMarketOrder.razor b/WebFrontend/Pages/EditMarketOrder.razor index 177bfa1..bf4a8fc 100644 --- a/WebFrontend/Pages/EditMarketOrder.razor +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -23,7 +23,7 @@

Edit a market order

- +
@@ -37,7 +37,7 @@ [Parameter] public int OrderId { get; set; } - protected EntryForm.NewOrderModel InitialOrderModel; + protected OrderForm.NewOrderModel InitialOrderModel; protected Portfolio ActivePortfolio; protected PortfolioEntry ActiveEntry; protected MarketOrder ActiveMarketOrder; @@ -55,7 +55,7 @@ InitialOrderModel.SellOrder = !ActiveMarketOrder.Buy; } - private void OnCreateOrderFormSubmit(EntryForm.NewOrderModel formModel) + private void OnCreateOrderFormSubmit(OrderForm.NewOrderModel formModel) { MarketOrderService.UpdateMarketOrder(ActiveMarketOrder with { FilledPrice = formModel.FilledPrice, diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor index 64e14b4..dcde27e 100644 --- a/WebFrontend/Pages/NewMarketOrder.razor +++ b/WebFrontend/Pages/NewMarketOrder.razor @@ -23,7 +23,7 @@

Create a new market order

- +
@@ -46,7 +46,7 @@ ActivePortfolio = PortfolioService.GetPortfolio(ActiveEntry.PortfolioId); } - private void OnCreateOrderFormSubmit(EntryForm.NewOrderModel formModel) + private void OnCreateOrderFormSubmit(OrderForm.NewOrderModel formModel) { Console.WriteLine("OnCreateOrderFormSubmit " + formModel); MarketOrderService.CreateMarketOrder(formModel.FilledPrice, formModel.Fee, formModel.Size, formModel.OrderDate, !formModel.SellOrder, ActiveEntry.Id); From ad2db7f25c96f1307404decacef5e40b200b503b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 21:51:25 +0200 Subject: [PATCH 49/71] Added comments to the EditMarketOrder.razor template --- WebFrontend/Pages/EditMarketOrder.razor | 48 ++++++++++++++++--------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/WebFrontend/Pages/EditMarketOrder.razor b/WebFrontend/Pages/EditMarketOrder.razor index bf4a8fc..ccdaadb 100644 --- a/WebFrontend/Pages/EditMarketOrder.razor +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -19,11 +19,11 @@
- Back + Back

Edit a market order

- +
@@ -34,30 +34,42 @@ @code { + // ID of the order to be edited [Parameter] public int OrderId { get; set; } - protected OrderForm.NewOrderModel InitialOrderModel; - protected Portfolio ActivePortfolio; - protected PortfolioEntry ActiveEntry; - protected MarketOrder ActiveMarketOrder; + // order form model + private OrderForm.NewOrderModel _initialOrderModel; + + // portfolio the order will belong to + private Portfolio _activePortfolio; + + // portfolio entry the order will belong to + private PortfolioEntry _activeEntry; + + // edited order + private MarketOrder _activeMarketOrder; protected override void OnInitialized() { - 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); + _activeEntry = PortfolioEntrySerivce.GetPortfolioEntry(_activeMarketOrder.PortfolioEntryId); + _activePortfolio = PortfolioService.GetPortfolio(_activeEntry.PortfolioId); + + // initialize the order form model + _initialOrderModel = new(); + _initialOrderModel.Fee = _activeMarketOrder.Fee; + _initialOrderModel.Size = _activeMarketOrder.Size; + _initialOrderModel.FilledPrice = _activeMarketOrder.FilledPrice; + _initialOrderModel.OrderDate = _activeMarketOrder.Date; + _initialOrderModel.SellOrder = !_activeMarketOrder.Buy; } private void OnCreateOrderFormSubmit(OrderForm.NewOrderModel formModel) { - MarketOrderService.UpdateMarketOrder(ActiveMarketOrder with { + // update the order + MarketOrderService.UpdateMarketOrder(_activeMarketOrder with { FilledPrice = formModel.FilledPrice, Fee = formModel.Fee, Size = formModel.Size, @@ -65,6 +77,8 @@ Buy = !formModel.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 From 572d30a4d448892df0d015b30950271f102ebc4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 22:08:20 +0200 Subject: [PATCH 50/71] Added comments to EditPortfolio.razor and Index.razor --- WebFrontend/Pages/EditPortfolio.razor | 46 +++++++++++++++------------ WebFrontend/Pages/Index.razor | 39 ++++++++++++----------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/WebFrontend/Pages/EditPortfolio.razor b/WebFrontend/Pages/EditPortfolio.razor index 86f506d..6af6085 100644 --- a/WebFrontend/Pages/EditPortfolio.razor +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -5,36 +5,29 @@ @inject IPortfolioService PortfolioService @inject IMatDialogService MatDialogService @inject IMatToaster Toaster -@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager +@inject NavigationManager NavigationManager
- Back + + Back +

Edit portfolio

@@ -48,27 +41,38 @@ @code { - [Parameter] public int PortfolioId { get; set; } + // ID of the portfolio to be edited + [Parameter] + public int PortfolioId { get; set; } + + // model of the form + private readonly PortfolioForm.NewPortfolioModel _formModel = new(Currency.Usd); - public PortfolioForm.NewPortfolioModel FormModel = new(Currency.Usd); - - public Portfolio ActivePortfolio; + // currently edited portfolio + private Portfolio _activePortfolio; protected override void OnInitialized() { - ActivePortfolio = PortfolioService.GetPortfolio(PortfolioId); - FormModel.Name = ActivePortfolio.Name; - FormModel.Description = ActivePortfolio.Description; + // find the portfolio + _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); + + // update the form model + _formModel.Name = _activePortfolio.Name; + _formModel.Description = _activePortfolio.Description; } private void OnCreateFormSubmitted(PortfolioForm.NewPortfolioModel formModel) { - PortfolioService.UpdatePortfolio(ActivePortfolio with { + // update the portfolio + PortfolioService.UpdatePortfolio(_activePortfolio with { Name = formModel.Name, Description = formModel.Description }); + // reset the form formModel.Reset(); Toaster.Add("Portfolio successfully edited", MatToastType.Success, "", ""); - NavigationManager.NavigateTo($"/portfolios/{ActivePortfolio.Id}"); + + // navigate back to the portfolio detail + NavigationManager.NavigateTo($"/portfolios/{_activePortfolio.Id}"); } } \ No newline at end of file diff --git a/WebFrontend/Pages/Index.razor b/WebFrontend/Pages/Index.razor index 071b820..b7cad60 100644 --- a/WebFrontend/Pages/Index.razor +++ b/WebFrontend/Pages/Index.razor @@ -1,8 +1,8 @@ @page "/" -@using Model @using Services @using Utils -@inject Microsoft.AspNetCore.Components.NavigationManager NavigationManager +@using Model +@inject NavigationManager NavigationManager @inject IPortfolioService PortfolioService @inject IPortfolioEntryService PortfolioEntryService @inject IMatDialogService MatDialogService @@ -26,15 +26,6 @@ bottom: 1rem; right: 1rem; } - - .mat-paper { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 1em; - } -
@@ -42,17 +33,22 @@
Portfolios - @if (PortfoliosWithEntries == null) + @if (_portfoliosWithEntries == null) { } - else if (PortfoliosWithEntries.Count < 1) + else if (_portfoliosWithEntries.Count < 1) { - No portfolios were found.
+ + No portfolios were found. +
+ +
+
} else { - @foreach (var portfolioWithEntries in PortfoliosWithEntries) + @foreach (var portfolioWithEntries in _portfoliosWithEntries) { @@ -96,12 +92,13 @@
- + @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); @@ -112,7 +109,7 @@ private void LoadPortfolios() { - PortfoliosWithEntries = PortfolioService.GetPortfolios().Select( + _portfoliosWithEntries = PortfolioService.GetPortfolios().Select( portfolio => new Tuple>( portfolio, PortfolioEntryService.GetPortfolioEntries(portfolio.Id) @@ -127,11 +124,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, "", ""); } @@ -141,7 +142,7 @@ { NavigationManager.NavigateTo($"/newportfolioentry/{portfolio.Id}"); } - + private void ViewPortfolio(Portfolio portfolio) { NavigationManager.NavigateTo($"/portfolios/{portfolio.Id}"); From 73e04284fe7a9766cbf260bcf20c720ee7d584b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 22:11:15 +0200 Subject: [PATCH 51/71] Added comments to NewMarketOrder.razor --- WebFrontend/Pages/NewMarketOrder.razor | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor index dcde27e..3034e10 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 @@ -81,13 +70,13 @@ - @if (PortfolioEntryRows == null) + @if (_portfolioEntryRows == null) { } - else if (PortfolioEntryRows.Count > 0) + else if (_portfolioEntryRows.Count > 0) { - + Coin Price @@ -97,18 +86,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 were found.
+ + No portfolio entries were found. +
+ +
+
} } else @@ -119,7 +113,7 @@
- + @code @@ -127,20 +121,20 @@ [Parameter] public int PortfolioId { get; set; } - protected Portfolio ActivePortfolio; + private Portfolio _activePortfolio; - protected ISummaryService.Summary PortfolioSummary = null; + private ISummaryService.Summary _portfolioSummary; - protected List ActivePortfolioEntries; + private List _activePortfolioEntries; - protected List PortfolioEntryRows; + 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); + _activePortfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); _loadEntryInfo(); } @@ -150,55 +144,49 @@ // resolve names of all portfolio entries await CryptocurrencyResolver.Refresh(); var portfolioCryptocurrencyEntries = await Task.WhenAll( - ActivePortfolioEntries.Select( + _activePortfolioEntries.Select( async entry => (await CryptocurrencyResolver.Resolve(entry.Symbol))) ); // fetch market entries of all entries of the portfolio var marketEntries = await CryptoStatsSource.GetMarketEntries( - CurrencyUtils.GetCurrencyLabel(ActivePortfolio.Currency).ToLower(), portfolioCryptocurrencyEntries.Select(c => c.Id).ToArray() + 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( + _portfolioEntryRows = entrySummaries.Zip(_activePortfolioEntries).Select( tuple => new PortfolioEntryRow( // symbol of the portfolio entry tuple.Second.Symbol, @@ -216,7 +204,8 @@ tuple.Second.Id ) ).ToList(); - + + // update the UI StateHasChanged(); } @@ -224,8 +213,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 From a59df29bce618b068df24646d63cd65bda2841ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 22:32:22 +0200 Subject: [PATCH 54/71] Added comments to PortfolioEntryDetail.razor --- WebFrontend/Pages/PortfolioEntryDetail.razor | 152 ++++++++++--------- 1 file changed, 82 insertions(+), 70 deletions(-) diff --git a/WebFrontend/Pages/PortfolioEntryDetail.razor b/WebFrontend/Pages/PortfolioEntryDetail.razor index c862b9c..d122540 100644 --- a/WebFrontend/Pages/PortfolioEntryDetail.razor +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -4,7 +4,7 @@ @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 @@ -38,39 +38,31 @@ right: 1rem; } - .mat-paper { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 1em; - } -
- BackPortfolio Entry + BackPortfolio Entry - @if(ActivePortfolioEntry != null) + @if(_activePortfolioEntry != null) {
- @if (PortfolioCryptocurrencyEntry != null) + @if (_portfolioCryptocurrencyEntry != null) { - @PortfolioCryptocurrencyEntry.Name + @_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 { @@ -81,31 +73,31 @@
- @if (EntrySummary != null) + @if (_entrySummary != null) {
- +
- +
- +
- +
- +
- +
@@ -123,17 +115,17 @@ }
- @if (TableRowsItems == null) + @if (_tableRowsItems == null) { } - else if (TableRowsItems.Count == 0) + else if (_tableRowsItems.Count == 0) { - No market orders were found.
+ No market orders were found.
} else { - + Date Size @@ -149,21 +141,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))%)
@@ -176,36 +168,36 @@
- + - + Market Order Detail - @if (OrderToBeShown != null) + @if (_orderToBeShown != null) {
- +
- +
- +
- +
- +
- +
@@ -218,44 +210,59 @@ @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 Cryptocurrency PortfolioCryptocurrencyEntry; - 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; + + // total holdings of the active entry + private decimal _totalHoldings; + + // flag indicating whether order's detail is open + private bool _orderDetailDialogIsOpen; - protected bool OrderDetailDialogIsOpen; - protected Tuple OrderToBeShown; + // order whose detail should be displayed + private Tuple _orderToBeShown; - protected List> TableRowsItems; + // 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); // 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) - PortfolioCryptocurrencyEntry = await CryptocurrencyResolver.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(); } @@ -263,27 +270,27 @@ { // fetch the price of the entry's asset - // TODO null? - CurrentEntryAssetMarketEntry = (await CryptoStatsSource.GetMarketEntries( - CurrencyUtils.GetCurrencyLabel(ActivePortfolio.Currency).ToLower(), - PortfolioCryptocurrencyEntry.Id + _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) @@ -293,12 +300,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, "", ""); } @@ -306,15 +317,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(); } @@ -322,6 +333,7 @@ { if (obj != null) { + // order has been clicked, show its detail ShowOrderDetail((Tuple) obj) ; } } From 7e82ed5b56874f88bd4715a8dbbfa726645199d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Sun, 23 May 2021 22:38:32 +0200 Subject: [PATCH 55/71] Added comments to PortfolioEntryManagement.razor --- .../Pages/PortfolioEntryManagement.razor | 97 ++++++++++--------- 1 file changed, 52 insertions(+), 45 deletions(-) diff --git a/WebFrontend/Pages/PortfolioEntryManagement.razor b/WebFrontend/Pages/PortfolioEntryManagement.razor index 04a949e..4679914 100644 --- a/WebFrontend/Pages/PortfolioEntryManagement.razor +++ b/WebFrontend/Pages/PortfolioEntryManagement.razor @@ -11,38 +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) { - @@ -89,6 +74,7 @@ set { _cryptocurrencyFilter = value; + // when setting the cryptocurrency symbol filter, do filter the list of available cryptos FilterCurrenciesBySymbol(value); this.StateHasChanged(); } @@ -96,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; @@ -105,57 +92,77 @@ [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); + + // 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(); + _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 From 41d2350064db981c594fc7d0be8edc063044c0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 07:19:26 +0200 Subject: [PATCH 56/71] Added comments to reusable components. Fixed page styles --- WebFrontend/Pages/EditMarketOrder.razor | 28 +++++++++---------- WebFrontend/Pages/EditPortfolio.razor | 25 +++++++++++------ WebFrontend/Pages/Index.razor | 8 ++++++ WebFrontend/Pages/NewMarketOrder.razor | 6 ++-- WebFrontend/Pages/NewPortfolio.razor | 6 ++-- WebFrontend/Pages/PortfolioDetail.razor | 8 ++++++ WebFrontend/Pages/PortfolioEntryDetail.razor | 8 ++++++ .../Pages/PortfolioEntryManagement.razor | 4 ++- WebFrontend/Properties/launchSettings.json | 2 +- WebFrontend/Shared/OrderForm.razor | 22 +++++++++++---- WebFrontend/Shared/PortfolioForm.razor | 14 +++++++--- 11 files changed, 91 insertions(+), 40 deletions(-) diff --git a/WebFrontend/Pages/EditMarketOrder.razor b/WebFrontend/Pages/EditMarketOrder.razor index ccdaadb..eb7f510 100644 --- a/WebFrontend/Pages/EditMarketOrder.razor +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -23,7 +23,7 @@

Edit a market order

- +
@@ -39,7 +39,7 @@ public int OrderId { get; set; } // order form model - private OrderForm.NewOrderModel _initialOrderModel; + private OrderForm.OrderFormModel _initialOrderFormModel; // portfolio the order will belong to private Portfolio _activePortfolio; @@ -58,23 +58,23 @@ _activePortfolio = PortfolioService.GetPortfolio(_activeEntry.PortfolioId); // initialize the order form model - _initialOrderModel = new(); - _initialOrderModel.Fee = _activeMarketOrder.Fee; - _initialOrderModel.Size = _activeMarketOrder.Size; - _initialOrderModel.FilledPrice = _activeMarketOrder.FilledPrice; - _initialOrderModel.OrderDate = _activeMarketOrder.Date; - _initialOrderModel.SellOrder = !_activeMarketOrder.Buy; + _initialOrderFormModel = new(); + _initialOrderFormModel.Fee = _activeMarketOrder.Fee; + _initialOrderFormModel.Size = _activeMarketOrder.Size; + _initialOrderFormModel.FilledPrice = _activeMarketOrder.FilledPrice; + _initialOrderFormModel.OrderDate = _activeMarketOrder.Date; + _initialOrderFormModel.SellOrder = !_activeMarketOrder.Buy; } - private void OnCreateOrderFormSubmit(OrderForm.NewOrderModel formModel) + private void OnCreateOrderFormSubmit(OrderForm.OrderFormModel formFormModel) { // update the order MarketOrderService.UpdateMarketOrder(_activeMarketOrder with { - FilledPrice = formModel.FilledPrice, - Fee = formModel.Fee, - Size = formModel.Size, - Date = formModel.OrderDate, - Buy = !formModel.SellOrder + FilledPrice = formFormModel.FilledPrice, + Fee = formFormModel.Fee, + Size = formFormModel.Size, + Date = formFormModel.OrderDate, + Buy = !formFormModel.SellOrder }); Toaster.Add("Order successfully edited", MatToastType.Success, "", ""); diff --git a/WebFrontend/Pages/EditPortfolio.razor b/WebFrontend/Pages/EditPortfolio.razor index 6af6085..31ecf46 100644 --- a/WebFrontend/Pages/EditPortfolio.razor +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -9,9 +9,18 @@
@@ -27,7 +36,7 @@ @@ -46,7 +55,7 @@ public int PortfolioId { get; set; } // model of the form - private readonly PortfolioForm.NewPortfolioModel _formModel = new(Currency.Usd); + private readonly PortfolioForm.PortfolioFormModel _formFormModel = new(Currency.Usd); // currently edited portfolio private Portfolio _activePortfolio; @@ -57,19 +66,19 @@ _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); // update the form model - _formModel.Name = _activePortfolio.Name; - _formModel.Description = _activePortfolio.Description; + _formFormModel.Name = _activePortfolio.Name; + _formFormModel.Description = _activePortfolio.Description; } - private void OnCreateFormSubmitted(PortfolioForm.NewPortfolioModel formModel) + private void OnCreateFormSubmitted(PortfolioForm.PortfolioFormModel formFormModel) { // update the portfolio PortfolioService.UpdatePortfolio(_activePortfolio with { - Name = formModel.Name, - Description = formModel.Description + Name = formFormModel.Name, + Description = formFormModel.Description }); // reset the form - formModel.Reset(); + formFormModel.Reset(); Toaster.Add("Portfolio successfully edited", MatToastType.Success, "", ""); // navigate back to the portfolio detail diff --git a/WebFrontend/Pages/Index.razor b/WebFrontend/Pages/Index.razor index b7cad60..aa50350 100644 --- a/WebFrontend/Pages/Index.razor +++ b/WebFrontend/Pages/Index.razor @@ -26,6 +26,14 @@ bottom: 1rem; right: 1rem; } + + .mat-paper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1em; + }
diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor index 3034e10..29fa288 100644 --- a/WebFrontend/Pages/NewMarketOrder.razor +++ b/WebFrontend/Pages/NewMarketOrder.razor @@ -52,12 +52,12 @@ _activePortfolio = PortfolioService.GetPortfolio(_activeEntry.PortfolioId); } - private void OnCreateOrderFormSubmit(OrderForm.NewOrderModel formModel) + private void OnCreateOrderFormSubmit(OrderForm.OrderFormModel formFormModel) { // create the market order - MarketOrderService.CreateMarketOrder(formModel.FilledPrice, formModel.Fee, formModel.Size, formModel.OrderDate, !formModel.SellOrder, _activeEntry.Id); + MarketOrderService.CreateMarketOrder(formFormModel.FilledPrice, formFormModel.Fee, formFormModel.Size, formFormModel.OrderDate, !formFormModel.SellOrder, _activeEntry.Id); // reset the form model - formModel.Reset(); + formFormModel.Reset(); // notify user Toaster.Add("New order successfully added", MatToastType.Success, "", ""); } diff --git a/WebFrontend/Pages/NewPortfolio.razor b/WebFrontend/Pages/NewPortfolio.razor index df8c38f..ba03c26 100644 --- a/WebFrontend/Pages/NewPortfolio.razor +++ b/WebFrontend/Pages/NewPortfolio.razor @@ -40,12 +40,12 @@ @code { - private void OnCreateFormSubmitted(PortfolioForm.NewPortfolioModel formModel) + private void OnCreateFormSubmitted(PortfolioForm.PortfolioFormModel formFormModel) { // create the portfolio - PortfolioService.CreatePortfolio(formModel.Name, formModel.Description, formModel.SelectedCurrency); + PortfolioService.CreatePortfolio(formFormModel.Name, formFormModel.Description, formFormModel.SelectedCurrency); // reset the model - formModel.Reset(); + formFormModel.Reset(); // notify the user Toaster.Add("New portfolio successfully added", MatToastType.Success, "", ""); } diff --git a/WebFrontend/Pages/PortfolioDetail.razor b/WebFrontend/Pages/PortfolioDetail.razor index 1263068..8a4c639 100644 --- a/WebFrontend/Pages/PortfolioDetail.razor +++ b/WebFrontend/Pages/PortfolioDetail.razor @@ -29,6 +29,14 @@ bottom: 1rem; right: 1rem; } + + .mat-paper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1em; + }
diff --git a/WebFrontend/Pages/PortfolioEntryDetail.razor b/WebFrontend/Pages/PortfolioEntryDetail.razor index d122540..fe61a36 100644 --- a/WebFrontend/Pages/PortfolioEntryDetail.razor +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -37,6 +37,14 @@ bottom: 1rem; right: 1rem; } + + .mat-paper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 1em; + }
diff --git a/WebFrontend/Pages/PortfolioEntryManagement.razor b/WebFrontend/Pages/PortfolioEntryManagement.razor index 4679914..503a4dc 100644 --- a/WebFrontend/Pages/PortfolioEntryManagement.razor +++ b/WebFrontend/Pages/PortfolioEntryManagement.razor @@ -119,7 +119,9 @@ protected override async Task OnInitializedAsync() { // find all available cryptocurrencies - _availableCryptocurrencies = await CryptoStatsSource.GetAvailableCryptocurrencies(); + _availableCryptocurrencies = (await CryptoStatsSource.GetAvailableCryptocurrencies()) + // workaround till Coingecko removes binance-peg entries + .Where(c => !c.Id.Contains("binance-peg")).ToList(); _filteredCryptocurrencies = _availableCryptocurrencies; UpdateAvailableCryptocurrencies(_availableCryptocurrencies); } diff --git a/WebFrontend/Properties/launchSettings.json b/WebFrontend/Properties/launchSettings.json index 7f00afe..780e130 100644 --- a/WebFrontend/Properties/launchSettings.json +++ b/WebFrontend/Properties/launchSettings.json @@ -19,7 +19,7 @@ "commandName": "Project", "dotnetRunMessages": "true", "launchBrowser": false, - "applicationUrl": "https://localhost:5001;http://localhost:5000", + "applicationUrl": "http://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/WebFrontend/Shared/OrderForm.razor b/WebFrontend/Shared/OrderForm.razor index bbde711..8ff3500 100644 --- a/WebFrontend/Shared/OrderForm.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; From 500fe3d2884559d3fa53041f0929350cff852f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 07:20:17 +0200 Subject: [PATCH 57/71] Reverted launchSettings.json change --- WebFrontend/Properties/launchSettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebFrontend/Properties/launchSettings.json b/WebFrontend/Properties/launchSettings.json index 780e130..7f00afe 100644 --- a/WebFrontend/Properties/launchSettings.json +++ b/WebFrontend/Properties/launchSettings.json @@ -19,7 +19,7 @@ "commandName": "Project", "dotnetRunMessages": "true", "launchBrowser": false, - "applicationUrl": "http://localhost:5001;http://localhost:5000", + "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From d7be82abe43b386b05a2bae2822e07dd22e83bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 14:38:54 +0200 Subject: [PATCH 58/71] Added app diagram --- doc/doc.tex | 7 +++++++ doc/img/app-diagram.pdf | Bin 0 -> 11838 bytes 2 files changed, 7 insertions(+) create mode 100644 doc/img/app-diagram.pdf diff --git a/doc/doc.tex b/doc/doc.tex index 482cf13..e05c4aa 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -200,6 +200,13 @@ \section{Popis architektury vytvořené 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. diff --git a/doc/img/app-diagram.pdf b/doc/img/app-diagram.pdf new file mode 100644 index 0000000000000000000000000000000000000000..313b25c3c006c16548709ef6381dc8585762df30 GIT binary patch literal 11838 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 literal 0 HcmV?d00001 From d29b8483e4621e2dfd894fb1756ea752c7ce4dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 15:15:24 +0200 Subject: [PATCH 59/71] Added checking of parameters to avoid NPEs --- WebFrontend/App.razor | 21 ++++++-- WebFrontend/Pages/EditMarketOrder.razor | 7 +++ WebFrontend/Pages/EditPortfolio.razor | 6 +++ WebFrontend/Pages/NewMarketOrder.razor | 6 +++ WebFrontend/Pages/PortfolioDetail.razor | 11 ++++ WebFrontend/Pages/PortfolioEntryDetail.razor | 6 +++ .../Pages/PortfolioEntryManagement.razor | 6 +++ WebFrontend/Pages/ResourceNotFound.razor | 50 +++++++++++++++++++ 8 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 WebFrontend/Pages/ResourceNotFound.razor diff --git a/WebFrontend/App.razor b/WebFrontend/App.razor index 4395f0d..6033d80 100644 --- a/WebFrontend/App.razor +++ b/WebFrontend/App.razor @@ -7,9 +7,22 @@ - -

Sorry, there's nothing at this address.

-
+ + +
+
+ +
+
+ + There is nothing at this address. + +
+
+
+
+
+
@@ -21,4 +34,4 @@ { Primary = MatThemeColors.BlueGrey._500.Value }; -} +} \ No newline at end of file diff --git a/WebFrontend/Pages/EditMarketOrder.razor b/WebFrontend/Pages/EditMarketOrder.razor index eb7f510..2cbca14 100644 --- a/WebFrontend/Pages/EditMarketOrder.razor +++ b/WebFrontend/Pages/EditMarketOrder.razor @@ -54,6 +54,13 @@ { // 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); diff --git a/WebFrontend/Pages/EditPortfolio.razor b/WebFrontend/Pages/EditPortfolio.razor index 31ecf46..ed5d490 100644 --- a/WebFrontend/Pages/EditPortfolio.razor +++ b/WebFrontend/Pages/EditPortfolio.razor @@ -65,6 +65,12 @@ // find the portfolio _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); + if (_activePortfolio == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + // update the form model _formFormModel.Name = _activePortfolio.Name; _formFormModel.Description = _activePortfolio.Description; diff --git a/WebFrontend/Pages/NewMarketOrder.razor b/WebFrontend/Pages/NewMarketOrder.razor index 29fa288..9c18a8d 100644 --- a/WebFrontend/Pages/NewMarketOrder.razor +++ b/WebFrontend/Pages/NewMarketOrder.razor @@ -48,6 +48,12 @@ // load the portfolio entry _activeEntry = PortfolioEntrySerivce.GetPortfolioEntry(EntryId); + if (_activeEntry == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + // load the portfolio the entry belongs to _activePortfolio = PortfolioService.GetPortfolio(_activeEntry.PortfolioId); } diff --git a/WebFrontend/Pages/PortfolioDetail.razor b/WebFrontend/Pages/PortfolioDetail.razor index 8a4c639..b253afb 100644 --- a/WebFrontend/Pages/PortfolioDetail.razor +++ b/WebFrontend/Pages/PortfolioDetail.razor @@ -126,15 +126,20 @@ @code { + // id of the portfolio whose detail should be shown [Parameter] public int PortfolioId { get; set; } + // portfolio whose detail should be shown private Portfolio _activePortfolio; + // summary of the portfolio private ISummaryService.Summary _portfolioSummary; + // entries of the portfolio private List _activePortfolioEntries; + // rows of the portfolio entry table private List _portfolioEntryRows; protected record PortfolioEntryRow(string Symbol, decimal CurrentPrice, decimal RelativeChange, decimal Percentage, decimal AbsoluteChange, decimal MarketValue, int EntryId); @@ -142,6 +147,12 @@ protected override void OnInitialized() { _activePortfolio = PortfolioService.GetPortfolio(PortfolioId); + if (_activePortfolio == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + _activePortfolioEntries = PortfolioEntryService.GetPortfolioEntries(PortfolioId); _loadEntryInfo(); } diff --git a/WebFrontend/Pages/PortfolioEntryDetail.razor b/WebFrontend/Pages/PortfolioEntryDetail.razor index fe61a36..0546ac7 100644 --- a/WebFrontend/Pages/PortfolioEntryDetail.razor +++ b/WebFrontend/Pages/PortfolioEntryDetail.razor @@ -254,6 +254,12 @@ // get the portfolio entry _activePortfolioEntry = PortfolioEntryService.GetPortfolioEntry(EntryId); + if (_activePortfolioEntry == null) + { + NavigationManager.NavigateTo("/notfound"); + return; + } + // get the entry's portfolio _activePortfolio = PortfolioService.GetPortfolio(_activePortfolioEntry.PortfolioId); } diff --git a/WebFrontend/Pages/PortfolioEntryManagement.razor b/WebFrontend/Pages/PortfolioEntryManagement.razor index 503a4dc..47624c5 100644 --- a/WebFrontend/Pages/PortfolioEntryManagement.razor +++ b/WebFrontend/Pages/PortfolioEntryManagement.razor @@ -112,6 +112,12 @@ // 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); } 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 +
+ +
+
+
+
+
+
From a91597df2c5cbe076a663d53ffb4312a26c5649c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 16:12:23 +0200 Subject: [PATCH 60/71] Extended and improved the documentation. --- doc/doc.tex | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index e05c4aa..a0531b2 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -308,9 +308,13 @@ \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. + 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 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 @@ -389,9 +393,15 @@ Dalším rozšířením by mohlo být pokrytí uživatelského rozhraní automatickými testy, například pomocí nástroje Robot framework. - \subsection{Programátorský deník} + 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}. + + \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 74 hodin: + 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 @@ -399,7 +409,7 @@ \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{4.5h} -- použití frameworku Electron + \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 From 14c39cf108633b99dd1e204b4b9d4279c087a420 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 16:13:25 +0200 Subject: [PATCH 61/71] Added ElectronBootstrap method to Startup.cs. --- WebFrontend/.config/dotnet-tools.json | 12 ++++++++++++ WebFrontend/Startup.cs | 20 +++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 WebFrontend/.config/dotnet-tools.json 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/Startup.cs b/WebFrontend/Startup.cs index 3142286..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; @@ -89,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 From b4052a9b0a6e5ec25592b77c61a89b252410a001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 16:33:41 +0200 Subject: [PATCH 62/71] Documentation spellchecking --- doc/doc.tex | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/doc/doc.tex b/doc/doc.tex index a0531b2..5bd854e 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -136,7 +136,7 @@ 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. - 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í. + 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í. \begin{figure}[!ht] \centering @@ -150,7 +150,7 @@ \subsection{Datový zdroj s aktuálním kurzem kryptoměn} - 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. + 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í. @@ -173,7 +173,7 @@ \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 nárážíme na problém, + 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} @@ -251,7 +251,7 @@ \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žá i všechny transakce, které k ní byly přiřazeny. + 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}} @@ -338,14 +338,14 @@ \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ýkonosti jednotlivých sledovaných entit + 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{Oveření kvality vytvořeného software} + \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}}. @@ -353,7 +353,7 @@ 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ískvání informací o aktuálním stavu trhu s kryptoměnami. + 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 @@ -499,7 +499,7 @@ \newpage \subsection{Formulář pro vytvoření či editaci transakce} - K vytvoření či editaci transakce slouží jednoduchý formmulář, do kterého je třeba zadat následující údaje: + 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 @@ -528,7 +528,7 @@ \begin{figure}[!ht] \centering {\includegraphics[width=\textwidth]{img/cpt-screenshots/portfolio-entry-mngmt.png}} - \caption{Obrazovka správy položek portólia} + \caption{Obrazovka správy položek portfólia} \label{fig:portfolio-entry-mngmnt} \end{figure} From 9c5b80be684440593a3a68f2cc26d7fe4dacc5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:05:24 +0200 Subject: [PATCH 63/71] Added reference to Electron.NET --- doc/doc.tex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/doc.tex b/doc/doc.tex index 5bd854e..3e25960 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -314,7 +314,7 @@ 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 k tomu, aby ji bylo možné spustit v Electron kontejneru. + 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 From 8feebd4de8dadc6af557cbac0126b618c2ce2b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:12:18 +0200 Subject: [PATCH 64/71] Summary update. --- doc/doc.tex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/doc.tex b/doc/doc.tex index 3e25960..b6e381a 100644 --- a/doc/doc.tex +++ b/doc/doc.tex @@ -399,6 +399,11 @@ \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: From 26215a8be25e3974aefe57a182a754b37008527d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:23:39 +0200 Subject: [PATCH 65/71] Added developer diary --- developer_diary.csv | 104 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 developer_diary.csv 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, From 119ef0ec46d13842bb5563f377f512ef3b7adf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:24:30 +0200 Subject: [PATCH 66/71] Updated yuml.me --- yuml.me | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/yuml.me b/yuml.me index de1ae50..020a338 100644 --- a/yuml.me +++ b/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] - - - From 95779fb62657db553d4ce6fb95d55c51b63c0f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:25:14 +0200 Subject: [PATCH 67/71] Renamed yuml.me to app_diagram.yuml.me --- yuml.me => app_diagram.yuml.me | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename yuml.me => app_diagram.yuml.me (100%) diff --git a/yuml.me b/app_diagram.yuml.me similarity index 100% rename from yuml.me rename to app_diagram.yuml.me From 0dd794f5a8a8c2f4c75f68b07e9d2f61f0ebdff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:28:40 +0200 Subject: [PATCH 68/71] Updated README.md --- README.md | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 48462bc..2cafed1 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,10 @@ # 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 +## 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 ## Electron ```electronize start /PublishSingleFile false /PublishReadyToRun false --no-self-contained``` - \ No newline at end of file + From bcda404210da735b68ed5cf361feb6c4af02bc9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:31:53 +0200 Subject: [PATCH 69/71] Updated README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2cafed1..555c3d3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Crypto Portfolio Tracker A tracker of your crypto portfolio, written in C# using .NET core. Made as KIV/NET semester project at Západočeská univerzita v Plzni. +![Application screenshot](doc/img/cpt-screenshots/portfolio-entry-detail.png) ## Features - create and manage portfolios - create and manage portfolio entries - add transactions that are linked to portfolio entries - see summaries of portfolios, portfolio entries and transactions + - based on current cryptocurrency market data fetched from [CoinGecko](https://www.coingecko.com/en/api) ## Electron ```electronize start /PublishSingleFile false /PublishReadyToRun false --no-self-contained``` From f6544c347f279fd0396c40e5b68e03a58b460733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Tue, 25 May 2021 17:37:19 +0200 Subject: [PATCH 70/71] Updated README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 555c3d3..ac79bb9 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,7 @@ A tracker of your crypto portfolio, written in C# using .NET core. Made as KIV/N - 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) then run: + ```electronize start /PublishSingleFile false /PublishReadyToRun false --no-self-contained``` From 84d4c38fc0bec5831c800367ce745eca6c20705e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanislav=20Kr=C3=A1l?= Date: Thu, 29 Jul 2021 12:40:14 +0200 Subject: [PATCH 71/71] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ac79bb9..891b8d5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A tracker of your crypto portfolio, written in C# using .NET core. Made as KIV/N - 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) then run: +- install required tools from [Electron.NET](https://github.com/ElectronNET/Electron.NET) and then run: ```electronize start /PublishSingleFile false /PublishReadyToRun false --no-self-contained```