Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Database/TypeConfigurations/PriceTargetTypeConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using GhostfolioSidekick.Model.Market;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace GhostfolioSidekick.Database.TypeConfigurations
{
public class PriceTargetTypeConfiguration : IEntityTypeConfiguration<PriceTarget>
{
public void Configure(EntityTypeBuilder<PriceTarget> 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();
}
}
}
10 changes: 10 additions & 0 deletions ExternalDataProvider/IPriceTargetRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace GhostfolioSidekick.ExternalDataProvider
{
internal interface IPriceTargetRepository
{
}
}
10 changes: 10 additions & 0 deletions ExternalDataProvider/ITargetPriceRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using GhostfolioSidekick.Model.Market;
using GhostfolioSidekick.Model.Symbols;

namespace GhostfolioSidekick.ExternalDataProvider
{
public interface ITargetPriceRepository
{
Task<PriceTarget?> GetPriceTarget(SymbolProfile symbol);
}
}
162 changes: 162 additions & 0 deletions ExternalDataProvider/TipRanks/TipRanksMatcher.cs
Original file line number Diff line number Diff line change
@@ -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<SymbolProfile?> 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<SuggestResult>();
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<string> GetSearchTerms(PartialSymbolIdentifier[] partialSymbolIdentifiers)
{
var searchTerms = partialSymbolIdentifiers.Select(id => id.Identifier).ToList();

return [.. searchTerms
.FilterInvalidNames()
.FilterEmpty()
.Distinct()
];
}

private async Task<List<SuggestResult>?> GetSuggestResponse(string searchTerm)
{
var suggestUrl = $"{SearchUrl}{searchTerm}";

using var httpClient = httpClientFactory.CreateClient();
var r = await httpClient.GetFromJsonAsync<List<SuggestResult>>(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})";
}
}
}
}
172 changes: 172 additions & 0 deletions ExternalDataProvider/TipRanks/TipRanksScraper.cs
Original file line number Diff line number Diff line change
@@ -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<TipRanksScraper> logger,
HttpClient httpClient) : ITargetPriceRepository
{
// Example https://www.tipranks.com/stocks/nl:asrnl/stock-forecast/payload.json

public async Task<PriceTarget?> 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<PriceTarget?> 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<TipRanksApiResponse>(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<TipRanksStock>? 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; }
}
}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using GhostfolioSidekick.Database;
using GhostfolioSidekick.Database;
using GhostfolioSidekick.ExternalDataProvider;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
Expand All @@ -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;

Expand Down
Loading
Loading