diff --git a/Database/TypeConfigurations/PriceTargetTypeConfiguration.cs b/Database/TypeConfigurations/PriceTargetTypeConfiguration.cs new file mode 100644 index 000000000..be4b3da1c --- /dev/null +++ b/Database/TypeConfigurations/PriceTargetTypeConfiguration.cs @@ -0,0 +1,21 @@ +using GhostfolioSidekick.Model.Market; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace GhostfolioSidekick.Database.TypeConfigurations +{ + public class PriceTargetTypeConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + builder.Property(x => x.HighestTargetPrice).IsRequired(); + builder.Property(x => x.AverageTargetPrice).IsRequired(); + builder.Property(x => x.LowestTargetPrice).IsRequired(); + builder.Property(x => x.Rating).IsRequired(); + builder.Property(x => x.NumberOfBuys).IsRequired(); + builder.Property(x => x.NumberOfHolds).IsRequired(); + builder.Property(x => x.NumberOfSells).IsRequired(); + } + } +} diff --git a/ExternalDataProvider/IPriceTargetRepository.cs b/ExternalDataProvider/IPriceTargetRepository.cs new file mode 100644 index 000000000..20fdec9fe --- /dev/null +++ b/ExternalDataProvider/IPriceTargetRepository.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace GhostfolioSidekick.ExternalDataProvider +{ + internal interface IPriceTargetRepository + { + } +} diff --git a/ExternalDataProvider/ITargetPriceRepository.cs b/ExternalDataProvider/ITargetPriceRepository.cs new file mode 100644 index 000000000..567fb4a37 --- /dev/null +++ b/ExternalDataProvider/ITargetPriceRepository.cs @@ -0,0 +1,10 @@ +using GhostfolioSidekick.Model.Market; +using GhostfolioSidekick.Model.Symbols; + +namespace GhostfolioSidekick.ExternalDataProvider +{ + public interface ITargetPriceRepository + { + Task GetPriceTarget(SymbolProfile symbol); + } +} diff --git a/ExternalDataProvider/TipRanks/TipRanksMatcher.cs b/ExternalDataProvider/TipRanks/TipRanksMatcher.cs new file mode 100644 index 000000000..ce51baf2a --- /dev/null +++ b/ExternalDataProvider/TipRanks/TipRanksMatcher.cs @@ -0,0 +1,162 @@ +using GhostfolioSidekick.Model.Activities; +using GhostfolioSidekick.Model.Symbols; +using GhostfolioSidekick.Utilities; +using System.Net.Http.Json; +using GhostfolioSidekick.Model; +using System.Diagnostics.CodeAnalysis; + +namespace GhostfolioSidekick.ExternalDataProvider.TipRanks +{ + public class TipRanksMatcher(IHttpClientFactory httpClientFactory) : ISymbolMatcher + { + [SuppressMessage("Sonar", "S1075:URIs should not be hardcoded", Justification = "External API endpoint is stable and required for integration")] + private const string SearchUrl = "https://autocomplete.tipranks.com/api/autocomplete/search?name="; + + private const string ForecastUrl = "https://www.tipranks.com/stocks/"; + private const string PostFixForecastUrl = "/forecast"; + + public string DataSource => Datasource.TIPRANKS; + + public async Task MatchSymbol(PartialSymbolIdentifier[] symbolIdentifiers) + { + var cleanedIdentifiers = symbolIdentifiers + .Where(x => IsIndividualStock(x)) + .ToArray(); + + if (cleanedIdentifiers.Length == 0) + { + return null; + } + + var searchTerms = GetSearchTerms(cleanedIdentifiers); + var searchResults = new List(); + foreach (var searchTerm in searchTerms) + { + var suggestResponse = await GetSuggestResponse(searchTerm); + if (suggestResponse != null && suggestResponse.Count > 0) + { + searchResults.AddRange(suggestResponse); + } + } + + if (searchResults.Count == 0) + { + return null; + } + + // Semantic matching and sort on score + var sortedResults = searchResults + .Where(x => x.Category == "ticker") // Only stocks for now + .Select(result => new + { + Result = result, + Score = SemanticMatcher.CalculateSemanticMatchScore( + [.. cleanedIdentifiers.Select(x => x.Identifier)], + [result.CleanedName ?? result.Label]) + }) + .OrderByDescending(x => x.Score) // Take the highest score + .ThenBy(x => x.Result.Value.Length) // Take the shorter Ticker + .ThenByDescending(x => x.Result.Label.Length) // Take the longer name + .ToList(); + var bestMatch = sortedResults + .FirstOrDefault(); + + if (bestMatch == null || bestMatch.Score <= 0) // Minimum score threshold + { + return null; + } + + // Build SymbolProfile from best match + var profile = new SymbolProfile( + symbol: bestMatch.Result.Value, + name: bestMatch.Result.Label, + dataSource: DataSource, + currency: Currency.NONE, + identifiers: [.. cleanedIdentifiers.Select(id => id.Identifier)], + assetClass: AssetClass.Equity, + assetSubClass: null, + countries: [], + sectors: []) + { + WebsiteUrl = $"{ForecastUrl}{bestMatch.Result.Value}{PostFixForecastUrl}" + }; + + return profile; + } + + private static bool IsIndividualStock(PartialSymbolIdentifier identifier) + { + if (identifier.AllowedAssetClasses?.Contains(AssetClass.Equity) ?? false) + { + return true; + } + + // If empty list or null, assume all asset classes are allowed; otherwise, false + if (identifier.AllowedAssetClasses == null || identifier.AllowedAssetClasses.Count == 0) + { + return true; + } + + return false; + } + + private static List GetSearchTerms(PartialSymbolIdentifier[] partialSymbolIdentifiers) + { + var searchTerms = partialSymbolIdentifiers.Select(id => id.Identifier).ToList(); + + return [.. searchTerms + .FilterInvalidNames() + .FilterEmpty() + .Distinct() + ]; + } + + private async Task?> GetSuggestResponse(string searchTerm) + { + var suggestUrl = $"{SearchUrl}{searchTerm}"; + + using var httpClient = httpClientFactory.CreateClient(); + var r = await httpClient.GetFromJsonAsync>(suggestUrl); + + // Remove delisted entries + if (r == null) + { + return null; + } + + r = [.. r.Where(x => !x.Label.Contains("(delisted)", StringComparison.OrdinalIgnoreCase))]; + + // Remove terms in the names like co., corp., inc., ltd. + r = [.. r.Select(x => + { + x.CleanedName = SymbolNameCleaner.CleanSymbolName(x.Label); + return x; + })]; + + return r; + } + + [SuppressMessage("Minor Code Smell", "S3459:Unassigned members should be removed", Justification = "Required for serialization")] + [SuppressMessage("CodeQuality", "S1144:Unused private types or members should be removed", Justification = "Used for deserialization")] + [SuppressMessage("Style", "S1104:Fields should not have public accessibility", Justification = "DTO for JSON deserialization")] + private sealed class SuggestResult + { + // [{"label":"ASR Nederland N.V","ticker":null,"value":"NL:ASRNL","category":"ticker","uid":"3da53095","countryId":14,"extraData":{"market":"Amsterdam","marketCap":12544542032},"stockType":12,"followers":14,"keywords":[]}] + + public string Label { get; set; } = string.Empty; + + public string Value { get; set; } = string.Empty; + + public string Category { get; set; } = string.Empty; + + public string Uid { get; set; } = string.Empty; + + public string CleanedName { get; set; } = string.Empty; + + public override string ToString() + { + return $"{Label} ({Value})"; + } + } + } +} diff --git a/ExternalDataProvider/TipRanks/TipRanksScraper.cs b/ExternalDataProvider/TipRanks/TipRanksScraper.cs new file mode 100644 index 000000000..a709f4d33 --- /dev/null +++ b/ExternalDataProvider/TipRanks/TipRanksScraper.cs @@ -0,0 +1,172 @@ +using GhostfolioSidekick.Model.Market; +using GhostfolioSidekick.Model.Symbols; +using Microsoft.Extensions.Logging; +using System.Text.Json.Serialization; + +namespace GhostfolioSidekick.ExternalDataProvider.TipRanks +{ + public class TipRanksScraper( + ILogger logger, + HttpClient httpClient) : ITargetPriceRepository + { + // Example https://www.tipranks.com/stocks/nl:asrnl/stock-forecast/payload.json + + public async Task GetPriceTarget(SymbolProfile symbol) + { + if (symbol == null || symbol.WebsiteUrl == null || symbol.DataSource != Datasource.TIPRANKS) + { + return null; + } + + var uri = new Uri(symbol.WebsiteUrl); // https://www.tipranks.com/stocks/nl:asrnl + var segments = uri.Segments; + if (segments.Length < 3) + { + logger.LogWarning("Invalid TipRanks URL format: {Url}", symbol.WebsiteUrl); + return null; + } + + var stockIdentifier = segments[2].TrimEnd('/'); // nl:asrnl + return await GetPriceTargetFromApi(stockIdentifier); + } + + private async Task GetPriceTargetFromApi(string stockIdentifier) + { + // Construct the TipRanks API URL + var apiUrl = $"https://www.tipranks.com/stocks/{stockIdentifier}/stock-forecast/payload.json"; + + // Make the HTTP request + var response = await httpClient.GetAsync(apiUrl); + + if (!response.IsSuccessStatusCode) + { + // Return empty PriceTarget if API call fails + logger.LogWarning("Failed to fetch data from TipRanks API for {StockIdentifier}. Status Code: {StatusCode}", stockIdentifier, response.StatusCode); + return null; + } + + var jsonContent = await response.Content.ReadAsStringAsync(); + + if (string.IsNullOrWhiteSpace(jsonContent)) + { + logger.LogWarning("Empty response from TipRanks API for {StockIdentifier}", stockIdentifier); + return null; + } + + // Deserialize the JSON response + var apiResponse = System.Text.Json.JsonSerializer.Deserialize(jsonContent); + + if (apiResponse?.Models?.Stocks == null || !apiResponse.Models.Stocks.Any()) + { + logger.LogWarning("No stock data found in TipRanks API response for {StockIdentifier}", stockIdentifier); + return null; + } + + // Find the stock with matching ID (case-insensitive) + var stockData = apiResponse.Models.Stocks.FirstOrDefault(s => + string.Equals(s.Id, stockIdentifier, StringComparison.OrdinalIgnoreCase)); + + if (stockData?.AnalystRatings?.All == null) + { + logger.LogWarning("No analyst ratings found for {StockIdentifier}", stockIdentifier); + return null; + } + + // Map the API response to PriceTarget model + return MapToPriceTarget(stockData.AnalystRatings.All); + } + + private static PriceTarget MapToPriceTarget(TipRanksAnalystRatings analystRatings) + { + // Parse currency, default to USD if not available or invalid + var currency = Model.Currency.USD; + // Note: TipRanks API doesn't seem to provide currency info in the new format, + // so we'll keep USD as default + + // Map consensus rating ID to AnalystRating enum + var rating = MapConsensusRating(analystRatings.Id); + + return new PriceTarget + { + HighestTargetPrice = new Model.Money(currency, analystRatings.HighPriceTarget), + AverageTargetPrice = new Model.Money(currency, analystRatings.PriceTarget?.Value ?? 0), + LowestTargetPrice = new Model.Money(currency, analystRatings.LowPriceTarget), + Rating = rating, + NumberOfBuys = analystRatings.Buy, + NumberOfHolds = analystRatings.Hold, + NumberOfSells = analystRatings.Sell + }; + } + + private static AnalystRating MapConsensusRating(string consensusRating) + { + return consensusRating?.ToLowerInvariant() switch + { + "strong buy" or "strongbuy" or "strongbuy" => AnalystRating.StrongBuy, + "buy" or "moderatebuy" => AnalystRating.Buy, + "hold" => AnalystRating.Hold, + "sell" or "moderatesell" => AnalystRating.Sell, + "strong sell" or "strongsell" => AnalystRating.StrongSell, + _ => AnalystRating.Hold // Default to Hold for unknown ratings + }; + } + + // TipRanks API response models + internal class TipRanksApiResponse + { + [JsonPropertyName("models")] + public TipRanksModels? Models { get; set; } + } + + internal class TipRanksModels + { + [JsonPropertyName("stocks")] + public List? Stocks { get; set; } + } + + internal class TipRanksStock + { + [JsonPropertyName("_id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("analystRatings")] + public TipRanksAnalystRatingsContainer? AnalystRatings { get; set; } + } + + internal class TipRanksAnalystRatingsContainer + { + [JsonPropertyName("all")] + public TipRanksAnalystRatings? All { get; set; } + } + + internal class TipRanksAnalystRatings + { + [JsonPropertyName("buy")] + public int Buy { get; set; } + + [JsonPropertyName("hold")] + public int Hold { get; set; } + + [JsonPropertyName("sell")] + public int Sell { get; set; } + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("priceTarget")] + public TipRanksPriceTargetValue? PriceTarget { get; set; } + + [JsonPropertyName("highPriceTarget")] + public decimal HighPriceTarget { get; set; } + + [JsonPropertyName("lowPriceTarget")] + public decimal LowPriceTarget { get; set; } + } + + internal class TipRanksPriceTargetValue + { + [JsonPropertyName("value")] + public decimal Value { get; set; } + } + } +} \ No newline at end of file diff --git a/ExternalDataProvider/TipRanks/TipRanksScraper_New.cs b/ExternalDataProvider/TipRanks/TipRanksScraper_New.cs new file mode 100644 index 000000000..e69de29bb diff --git a/GhostfolioSidekick/MarketDataMaintainer/GatherDividendsTask.cs b/GhostfolioSidekick/MarketDataMaintainer/GatherDividendsTask.cs index 741d79b31..984402557 100644 --- a/GhostfolioSidekick/MarketDataMaintainer/GatherDividendsTask.cs +++ b/GhostfolioSidekick/MarketDataMaintainer/GatherDividendsTask.cs @@ -1,4 +1,4 @@ -using GhostfolioSidekick.Database; +using GhostfolioSidekick.Database; using GhostfolioSidekick.ExternalDataProvider; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -11,7 +11,7 @@ internal class GatherDividendsTask( { public TaskPriority Priority => TaskPriority.MarketDataDividends; - public TimeSpan ExecutionFrequency => Frequencies.Hourly; + public TimeSpan ExecutionFrequency => Frequencies.Daily; public bool ExceptionsAreFatal => false; diff --git a/GhostfolioSidekick/MarketDataMaintainer/MarketDataTargetPriceTask.cs b/GhostfolioSidekick/MarketDataMaintainer/MarketDataTargetPriceTask.cs new file mode 100644 index 000000000..d3cbd0f5a --- /dev/null +++ b/GhostfolioSidekick/MarketDataMaintainer/MarketDataTargetPriceTask.cs @@ -0,0 +1,48 @@ +using GhostfolioSidekick.Database; +using GhostfolioSidekick.ExternalDataProvider; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace GhostfolioSidekick.MarketDataMaintainer +{ + internal class MarketDataTargetPriceTask( + IDbContextFactory databaseContextFactory, + ITargetPriceRepository targetPriceRepository) : IScheduledWork + { + public TaskPriority Priority => TaskPriority.MarketDataTargetPrice; + + public TimeSpan ExecutionFrequency => Frequencies.Daily; + + public bool ExceptionsAreFatal => false; + + public string Name => "Getting Target Price Task"; + + public async Task DoWork(ILogger logger) + { + using var databaseContext = await databaseContextFactory.CreateDbContextAsync(); + var symbols = await databaseContext.SymbolProfiles + .Include(s => s.PriceTarget) + .ToListAsync(); + + foreach (var symbol in symbols) + { + logger.LogDebug("Processing price targets for symbol {Symbol}", symbol.Symbol); + + try + { + var gatheredPriceTarget = await targetPriceRepository.GetPriceTarget(symbol); + + // Replace existing price target or add new one + symbol.PriceTarget = gatheredPriceTarget; + } + catch (Exception ex) + { + logger.LogError(ex, "Error gathering price target for symbol {Symbol}", symbol.Symbol); + } + } + + // Save changes + await databaseContext.SaveChangesAsync(); + } + } +} diff --git a/GhostfolioSidekick/Program.cs b/GhostfolioSidekick/Program.cs index 5e4561b6d..75a086880 100644 --- a/GhostfolioSidekick/Program.cs +++ b/GhostfolioSidekick/Program.cs @@ -1,4 +1,4 @@ -using CoinGecko.Net.Clients; +using CoinGecko.Net.Clients; using CoinGecko.Net.Interfaces; using CoinGecko.Net.Objects.Options; using GhostfolioSidekick.Activities.Strategies; @@ -9,6 +9,7 @@ using GhostfolioSidekick.ExternalDataProvider.CoinGecko; using GhostfolioSidekick.ExternalDataProvider.DividendMax; using GhostfolioSidekick.ExternalDataProvider.Manual; +using GhostfolioSidekick.ExternalDataProvider.TipRanks; using GhostfolioSidekick.ExternalDataProvider.Yahoo; using GhostfolioSidekick.GhostfolioAPI; using GhostfolioSidekick.GhostfolioAPI.API; @@ -107,6 +108,7 @@ internal static IHostBuilder CreateHostBuilder() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(sp => new CoinGeckoRestClient( sp.GetRequiredService(), @@ -119,14 +121,16 @@ internal static IHostBuilder CreateHostBuilder() sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() ]); services.AddSingleton(sp => [sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService()]); services.AddSingleton(sp => [sp.GetRequiredService()]); services.AddSingleton(); services.AddSingleton(); - services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); RegisterAllWithInterface(services); diff --git a/GhostfolioSidekick/TaskPriority.cs b/GhostfolioSidekick/TaskPriority.cs index e9f732cbd..8235b88ed 100644 --- a/GhostfolioSidekick/TaskPriority.cs +++ b/GhostfolioSidekick/TaskPriority.cs @@ -1,4 +1,4 @@ -namespace GhostfolioSidekick +namespace GhostfolioSidekick { public enum TaskPriority { @@ -24,6 +24,8 @@ public enum TaskPriority MarketDataGatherer, + MarketDataTargetPrice, + CalculatePrice, PerformanceCalculations, diff --git a/Model/Market/AnalystRating.cs b/Model/Market/AnalystRating.cs new file mode 100644 index 000000000..dba08100e --- /dev/null +++ b/Model/Market/AnalystRating.cs @@ -0,0 +1,11 @@ +namespace GhostfolioSidekick.Model.Market +{ + public enum AnalystRating + { + StrongBuy, + Buy, + Hold, + Sell, + StrongSell + } +} diff --git a/Model/Market/PriceTarget.cs b/Model/Market/PriceTarget.cs new file mode 100644 index 000000000..11d1b8766 --- /dev/null +++ b/Model/Market/PriceTarget.cs @@ -0,0 +1,21 @@ +namespace GhostfolioSidekick.Model.Market +{ + public class PriceTarget + { + public int Id { get; set; } + + public Money HighestTargetPrice { get; set; } = default!; + + public Money AverageTargetPrice { get; set; } = default!; + + public Money LowestTargetPrice { get; set; } = default!; + + public AnalystRating Rating { get; set; } + + public int NumberOfBuys { get; set; } + + public int NumberOfHolds { get; set; } + + public int NumberOfSells { get; set; } + } +} diff --git a/Model/Symbols/Datasource.cs b/Model/Symbols/Datasource.cs index 77888a479..4d61fec8c 100644 --- a/Model/Symbols/Datasource.cs +++ b/Model/Symbols/Datasource.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace GhostfolioSidekick.Model.Symbols { @@ -15,6 +15,8 @@ public static class Datasource public static readonly string DividendMax = "DIVIDENDMAX"; + public static readonly string TIPRANKS = "TIPRANKS"; + public static string GetUnderlyingDataSource(string dataSource) { if (!IsGhostfolio(dataSource)) diff --git a/Model/Symbols/SymbolProfile.cs b/Model/Symbols/SymbolProfile.cs index 43f16d8d0..61acd5140 100644 --- a/Model/Symbols/SymbolProfile.cs +++ b/Model/Symbols/SymbolProfile.cs @@ -71,6 +71,8 @@ public SymbolProfile( public virtual ICollection StockSplits { get; set; } = []; public virtual ICollection Dividends { get; set; } = []; + + public virtual PriceTarget? PriceTarget { get; set; } public override string ToString() {