diff --git a/YahooFinanceApi.Tests/DecimalComparerWithPrecision.cs b/YahooFinanceApi.Tests/DecimalComparerWithPrecision.cs new file mode 100644 index 0000000..b106bd4 --- /dev/null +++ b/YahooFinanceApi.Tests/DecimalComparerWithPrecision.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace YahooFinanceApi.Tests; + +public class DecimalComparerWithPrecision : IEqualityComparer +{ + private readonly decimal precision; + + public DecimalComparerWithPrecision(decimal precision) + { + this.precision = precision; + } + + public bool Equals(decimal x, decimal y) + { + return Math.Abs(x - y) < precision; + } + + public int GetHashCode(decimal obj) + { + return obj.GetHashCode(); + } + + public static DecimalComparerWithPrecision defaultComparer = new DecimalComparerWithPrecision(0.00001m); + public static DecimalComparerWithPrecision Default => defaultComparer; +} \ No newline at end of file diff --git a/YahooFinanceApi.Tests/HistoricalTests.cs b/YahooFinanceApi.Tests/HistoricalTests.cs index 0882f4b..d61a13e 100644 --- a/YahooFinanceApi.Tests/HistoricalTests.cs +++ b/YahooFinanceApi.Tests/HistoricalTests.cs @@ -1,6 +1,8 @@ using System; using System.Linq; +using System.Net; using System.Threading.Tasks; +using Flurl.Http; using Xunit; using Xunit.Abstractions; @@ -17,12 +19,10 @@ public HistoricalTests(ITestOutputHelper output) [Fact] public async Task InvalidSymbolTest() { - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await Yahoo.GetHistoricalAsync("invalidSymbol", new DateTime(2017, 1, 3), new DateTime(2017, 1, 4))); - Write(exception.ToString()); - - Assert.Contains("Not Found", exception.InnerException.Message); + Assert.Equal((int)HttpStatusCode.NotFound, exception.StatusCode); } [Fact] @@ -31,13 +31,13 @@ public async Task PeriodTest() var date = new DateTime(2023, 1, 9); var candles = await Yahoo.GetHistoricalAsync("AAPL", date, date.AddDays(1), Period.Daily); - Assert.Equal(130.470001m, candles.First().Open); + Assert.Equal(130.470001m, candles.First().Open, DecimalComparerWithPrecision.Default); candles = await Yahoo.GetHistoricalAsync("AAPL", date, date.AddDays(7), Period.Weekly); - Assert.Equal(130.470001m, candles.First().Open); + Assert.Equal(130.470001m, candles.First().Open, DecimalComparerWithPrecision.Default); candles = await Yahoo.GetHistoricalAsync("AAPL", new DateTime(2023, 1, 1), new DateTime(2023, 2, 1), Period.Monthly); - Assert.Equal(130.279999m, candles.First().Open); + Assert.Equal(130.279999m, candles.First().Open, DecimalComparerWithPrecision.Default); } [Fact] @@ -46,18 +46,25 @@ public async Task HistoricalTest() var candles = await Yahoo.GetHistoricalAsync("AAPL", new DateTime(2023, 1, 3), new DateTime(2023, 1, 4), Period.Daily); var candle = candles.First(); - Assert.Equal(130.279999m, candle.Open); - Assert.Equal(130.899994m, candle.High); - Assert.Equal(124.169998m, candle.Low); - Assert.Equal(125.070000m, candle.Close); - Assert.Equal(112_117_500, candle.Volume); + Assert.Equal(130.279999m, candle.Open, DecimalComparerWithPrecision.Default); + Assert.Equal(130.899994m, candle.High, DecimalComparerWithPrecision.Default); + Assert.Equal(124.169998m, candle.Low, DecimalComparerWithPrecision.Default); + Assert.Equal(125.070000m, candle.Close, DecimalComparerWithPrecision.Default); + Assert.Equal(112_117_500, candle.Volume, DecimalComparerWithPrecision.Default); } - + [Fact] public async Task DividendTest() { var dividends = await Yahoo.GetDividendsAsync("AAPL", new DateTime(2016, 2, 4), new DateTime(2016, 2, 5)); - Assert.Equal(0.130000m, dividends.First().Dividend); + Assert.Equal(0.130000m, dividends.First().Dividend, DecimalComparerWithPrecision.Default); + } + + [Fact] + public async Task NoDividendTest() + { + var dividends = await Yahoo.GetDividendsAsync("ADXN.SW", new DateTime(2000, 1, 1), new DateTime(2024, 09, 10)); + Assert.Empty(dividends); } [Fact] @@ -82,23 +89,23 @@ public async Task DatesTest_US() Assert.Equal(from, candles.First().DateTime); Assert.Equal(to.Date, candles.Last().DateTime); - Assert.Equal(75.18m, candles[0].Close); - Assert.Equal(74.940002m, candles[1].Close); - Assert.Equal(72.370003m, candles[2].Close); + Assert.Equal(75.18m, candles[0].Close, DecimalComparerWithPrecision.Default); + Assert.Equal(74.940002m, candles[1].Close, DecimalComparerWithPrecision.Default); + Assert.Equal(72.370003m, candles[2].Close, DecimalComparerWithPrecision.Default); } [Fact] public async Task Test_UK() { var from = new DateTime(2017, 10, 10); - var to = new DateTime(2017, 10, 13); + var to = new DateTime(2017, 10, 12); var candles = await Yahoo.GetHistoricalAsync("BA.L", from, to, Period.Daily); Assert.Equal(3, candles.Count()); Assert.Equal(from, candles.First().DateTime); - Assert.Equal(to, candles.Last().DateTime.AddDays(1)); + Assert.Equal(to, candles.Last().DateTime); Assert.Equal(616.50m, candles[0].Close); Assert.Equal(615.00m, candles[1].Close); @@ -118,9 +125,9 @@ public async Task DatesTest_TW() Assert.Equal(from, candles.First().DateTime); Assert.Equal(to, candles.Last().DateTime); - Assert.Equal(71.599998m, candles[0].Close); - Assert.Equal(71.599998m, candles[1].Close); - Assert.Equal(73.099998m, candles[2].Close); + Assert.Equal(71.599998m, candles[0].Close, DecimalComparerWithPrecision.Default); + Assert.Equal(71.599998m, candles[1].Close, DecimalComparerWithPrecision.Default); + Assert.Equal(73.099998m, candles[2].Close, DecimalComparerWithPrecision.Default); } [Theory] @@ -141,7 +148,7 @@ public async Task DatesTest(params string[] symbols) var to = from.AddDays(2).AddHours(12); // start tasks - var tasks = symbols.Select(symbol => Yahoo.GetHistoricalAsync(symbol, from, to)); + var tasks = symbols.Select(symbol => Yahoo.GetHistoricalAsync(symbol, from, to, Period.Daily)); // wait for all tasks to complete var results = await Task.WhenAll(tasks.ToArray()); @@ -176,11 +183,10 @@ public async Task CurrencyTest() Assert.Equal(3, candles.Count()); - Assert.Equal(1.174164m, candles[0].Close); - Assert.Equal(1.181488m, candles[1].Close); - Assert.Equal(1.186549m, candles[2].Close); + Assert.Equal(1.174164m, candles[0].Close, DecimalComparerWithPrecision.Default); + Assert.Equal(1.181488m, candles[1].Close, DecimalComparerWithPrecision.Default); + Assert.Equal(1.186549m, candles[2].Close, DecimalComparerWithPrecision.Default); - // Note: Forex seems to return date = (requested date - 1 day) Assert.Equal(from, candles.First().DateTime); Assert.Equal(to, candles.Last().DateTime); } diff --git a/YahooFinanceApi.Tests/ProfileTests.cs b/YahooFinanceApi.Tests/ProfileTests.cs new file mode 100644 index 0000000..dfbbddc --- /dev/null +++ b/YahooFinanceApi.Tests/ProfileTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace YahooFinanceApi.Tests; + +public class ProfileTests +{ + [Fact] + public async Task TestProfileAsync() + { + const string AAPL = "AAPL"; + + var aaplProfile = await Yahoo.QueryProfileAsync(AAPL); + + Assert.NotNull(aaplProfile.Address1); + Assert.NotNull(aaplProfile.AuditRisk); + Assert.NotNull(aaplProfile.BoardRisk); + Assert.NotNull(aaplProfile.City); + Assert.NotNull(aaplProfile.CompanyOfficers); + Assert.NotNull(aaplProfile.CompensationAsOfEpochDate); + Assert.NotNull(aaplProfile.CompensationRisk); + Assert.NotNull(aaplProfile.Country); + Assert.NotNull(aaplProfile.FullTimeEmployees); + Assert.NotNull(aaplProfile.GovernanceEpochDate); + Assert.NotNull(aaplProfile.Industry); + Assert.NotNull(aaplProfile.IndustryDisp); + Assert.NotNull(aaplProfile.IndustryKey); + Assert.NotNull(aaplProfile.LongBusinessSummary); + Assert.NotNull(aaplProfile.MaxAge); + Assert.NotNull(aaplProfile.State); + Assert.NotNull(aaplProfile.Zip); + Assert.NotNull(aaplProfile.Phone); + Assert.NotNull(aaplProfile.Website); + Assert.NotNull(aaplProfile.Sector); + Assert.NotNull(aaplProfile.SectorKey); + Assert.NotNull(aaplProfile.SectorDisp); + Assert.NotNull(aaplProfile.ShareHolderRightsRisk); + Assert.NotNull(aaplProfile.OverallRisk); + } +} \ No newline at end of file diff --git a/YahooFinanceApi/Cache.cs b/YahooFinanceApi/Cache.cs new file mode 100644 index 0000000..30242fe --- /dev/null +++ b/YahooFinanceApi/Cache.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace YahooFinanceApi; + +public static class Cache +{ + private static Dictionary timeZoneCache = new Dictionary(); + + public static async Task GetTimeZone(string ticker) + { + if (timeZoneCache.TryGetValue(ticker, out var zone)) + return zone; + + var timeZone = await RequestTimeZone(ticker); + timeZoneCache[ticker] = timeZone; + return timeZone; + } + + private static async Task RequestTimeZone(string ticker) + { + var startTime = DateTime.Now.AddDays(-2); + var endTime = DateTime.Now; + var data = await ChartDataLoader.GetResponseStreamAsync(ticker, startTime, endTime, Period.Daily, ShowOption.History.Name(), CancellationToken.None); + var timeZoneName = data.chart.result[0].meta.exchangeTimezoneName; + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneName); + } + catch (TimeZoneNotFoundException e) + { + return TimeZoneInfo.Utc; + } + } +} \ No newline at end of file diff --git a/YahooFinanceApi/Candle.cs b/YahooFinanceApi/Candle.cs index 762067a..8e70a6d 100644 --- a/YahooFinanceApi/Candle.cs +++ b/YahooFinanceApi/Candle.cs @@ -1,21 +1,21 @@ -using System; - -namespace YahooFinanceApi -{ - public sealed class Candle: ITick - { - public DateTime DateTime { get; internal set; } - - public decimal Open { get; internal set; } - - public decimal High { get; internal set; } - - public decimal Low { get; internal set; } - - public decimal Close { get; internal set; } - - public long Volume { get; internal set; } - - public decimal AdjustedClose { get; internal set; } - } -} +using System; + +namespace YahooFinanceApi +{ + public sealed class Candle: ITick + { + public DateTime DateTime { get; internal set; } + + public decimal Open { get; internal set; } + + public decimal High { get; internal set; } + + public decimal Low { get; internal set; } + + public decimal Close { get; internal set; } + + public long Volume { get; internal set; } + + public decimal AdjustedClose { get; internal set; } + } +} diff --git a/YahooFinanceApi/ChartDataLoader.cs b/YahooFinanceApi/ChartDataLoader.cs new file mode 100644 index 0000000..077af87 --- /dev/null +++ b/YahooFinanceApi/ChartDataLoader.cs @@ -0,0 +1,41 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Flurl; +using Flurl.Http; + +namespace YahooFinanceApi; + +public static class ChartDataLoader +{ + public static async Task GetResponseStreamAsync(string symbol, DateTime startTime, DateTime endTime, Period period, string events, CancellationToken token) + { + var url = "https://query2.finance.yahoo.com/v8/finance/chart/" + .AppendPathSegment(symbol) + .SetQueryParam("period1", startTime.ToUnixTimestamp()) + .SetQueryParam("period2", endTime.ToUnixTimestamp()) + .SetQueryParam("interval", $"1{period.Name()}") + .SetQueryParam("events", events) + .SetQueryParam("crumb", YahooSession.Crumb); + + Debug.WriteLine(url); + + var response = await url + .WithCookie(YahooSession.Cookie.Name, YahooSession.Cookie.Value) + .WithHeader(YahooSession.UserAgentKey, YahooSession.UserAgentValue) + // .AllowHttpStatus("500") + .GetAsync(token); + + var json = await response.GetJsonAsync(); + + var error = json.chart?.error?.description; + if (error != null) + { + throw new InvalidDataException($"An error was returned by Yahoo: {error}"); + } + + return json; + } +} \ No newline at end of file diff --git a/YahooFinanceApi/DataConvertors.cs b/YahooFinanceApi/DataConvertors.cs new file mode 100644 index 0000000..4cf30cf --- /dev/null +++ b/YahooFinanceApi/DataConvertors.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace YahooFinanceApi +{ + internal static class DataConvertors + { + internal static bool IgnoreEmptyRows; + + internal static List ToCandle(dynamic data, TimeZoneInfo timeZone) + { + List timestamps = data.timestamp; + DateTime[] dates = timestamps.Select(x => x.ToDateTime(timeZone).Date).ToArray(); + IDictionary indicators = data.indicators; + IDictionary values = data.indicators.quote[0]; + + if (indicators.ContainsKey("adjclose")) + values["adjclose"] = data.indicators.adjclose[0].adjclose; + + var ticks = new List(); + + for (int i = 0; i < dates.Length; i++) + { + var slice = new Dictionary(); + foreach (KeyValuePair pair in values) + { + List ts = (List) pair.Value; + slice.Add(pair.Key, ts[i]); + } + ticks.Add(CreateCandle(dates[i], slice)); + } + + return ticks; + + Candle CreateCandle(DateTime date, IDictionary row) + { + var candle = new Candle + { + DateTime = date, + Open = row.GetValueOrDefault("open").ToDecimal(), + High = row.GetValueOrDefault("high").ToDecimal(), + Low = row.GetValueOrDefault("low").ToDecimal(), + Close = row.GetValueOrDefault("close").ToDecimal(), + AdjustedClose = row.GetValueOrDefault("adjclose").ToDecimal(), + Volume = row.GetValueOrDefault("volume").ToInt64() + }; + + if (IgnoreEmptyRows && + candle.Open == 0 && candle.High == 0 && candle.Low == 0 && candle.Close == 0 && + candle.AdjustedClose == 0 && candle.Volume == 0) + return null; + + return candle; + } + } + + internal static List ToDividendTick(dynamic data, TimeZoneInfo timeZone) + { + IDictionary expandoObject = data; + + if (!expandoObject.ContainsKey("events")) + return new List(); + + IDictionary dvdObj = data.events.dividends; + var dividends = dvdObj.Values.Select(x => new DividendTick(ToDateTime(x.date, timeZone), ToDecimal(x.amount))).ToList(); + + if (IgnoreEmptyRows) + dividends = dividends.Where(x => x.Dividend > 0).ToList(); + + return dividends; + } + + internal static List ToSplitTick(dynamic data, TimeZoneInfo timeZone) + { + IDictionary splitsObj = data.events.splits; + var splits = splitsObj.Values.Select(x => new SplitTick(ToDateTime(x.date, timeZone), ToDecimal(x.numerator), ToDecimal(x.denominator))).ToList(); + + if (IgnoreEmptyRows) + splits = splits.Where(x => x.BeforeSplit > 0 && x.AfterSplit > 0).ToList(); + + return splits; + } + + private static DateTime ToDateTime(this object obj, TimeZoneInfo timeZone) + { + if (obj is long lng) + { + return TimeZoneInfo.ConvertTimeFromUtc(DateTimeOffset.FromUnixTimeSeconds(lng).DateTime, timeZone); + } + + throw new Exception($"Could not convert '{obj}' to DateTime."); + } + + private static Decimal ToDecimal(this object obj) + { + return Convert.ToDecimal(obj); + } + + private static Int64 ToInt64(this object obj) + { + return Convert.ToInt64(obj); + } + } +} diff --git a/YahooFinanceApi/DividendTick.cs b/YahooFinanceApi/DividendTick.cs index d855c3f..1262a88 100644 --- a/YahooFinanceApi/DividendTick.cs +++ b/YahooFinanceApi/DividendTick.cs @@ -1,11 +1,21 @@ -using System; - -namespace YahooFinanceApi -{ - public sealed class DividendTick : ITick - { - public DateTime DateTime { get; internal set; } - - public decimal Dividend { get; internal set; } - } -} +using System; + +namespace YahooFinanceApi +{ + public sealed class DividendTick : ITick + { + public DividendTick() + { + } + + internal DividendTick(DateTime dateTime, decimal dividend) + { + DateTime = dateTime; + Dividend = dividend; + } + + public DateTime DateTime { get; internal set; } + + public decimal Dividend { get; internal set; } + } +} diff --git a/YahooFinanceApi/Helper.cs b/YahooFinanceApi/Helper.cs index a065372..a2c1f2a 100644 --- a/YahooFinanceApi/Helper.cs +++ b/YahooFinanceApi/Helper.cs @@ -8,18 +8,22 @@ namespace YahooFinanceApi { internal static class Helper { - private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + public static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - private static readonly TimeZoneInfo TzEst = TimeZoneInfo - .GetSystemTimeZones() - .Single(tz => tz.Id == "Eastern Standard Time" || tz.Id == "America/New_York"); - - private static DateTime ToUtcFrom(this DateTime dt, TimeZoneInfo tzi) => - TimeZoneInfo.ConvertTimeToUtc(dt, tzi); - - internal static DateTime FromEstToUtc(this DateTime dt) => - DateTime.SpecifyKind(dt, DateTimeKind.Unspecified) - .ToUtcFrom(TzEst); + public static DateTime ToUtcFrom(this DateTime dt, TimeZoneInfo tzi) + { + switch (dt.Kind) + { + case DateTimeKind.Local: + return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(dt, DateTimeKind.Unspecified), tzi); + case DateTimeKind.Unspecified: + return TimeZoneInfo.ConvertTimeToUtc(dt, tzi); + case DateTimeKind.Utc: + return dt; + default: + throw new ArgumentOutOfRangeException(); + } + } internal static string ToUnixTimestamp(this DateTime dt) => DateTime.SpecifyKind(dt, DateTimeKind.Utc) @@ -35,9 +39,6 @@ internal static string Name(this T @enum) return name; } - internal static string GetRandomString(int length) => - Guid.NewGuid().ToString().Substring(0, length); - internal static string ToLowerCamel(this string pascal) => pascal.Substring(0, 1).ToLower() + pascal.Substring(1); diff --git a/YahooFinanceApi/ITick.cs b/YahooFinanceApi/ITick.cs index bb02310..57f2bde 100644 --- a/YahooFinanceApi/ITick.cs +++ b/YahooFinanceApi/ITick.cs @@ -1,4 +1,5 @@ using System; + namespace YahooFinanceApi { public interface ITick diff --git a/YahooFinanceApi/ProfileFields.cs b/YahooFinanceApi/ProfileFields.cs new file mode 100644 index 0000000..06df773 --- /dev/null +++ b/YahooFinanceApi/ProfileFields.cs @@ -0,0 +1,29 @@ +namespace YahooFinanceApi; + +public enum ProfileFields +{ + Address1, + City, + State, + Zip, + Country, + Phone, + Website, + Industry, + IndustryKey, + IndustryDisp, + Sector, + SectorKey, + SectorDisp, + LongBusinessSummary, + FullTimeEmployees, + CompanyOfficers, + AuditRisk, + BoardRisk, + CompensationRisk, + ShareHolderRightsRisk, + OverallRisk, + GovernanceEpochDate, + CompensationAsOfEpochDate, + MaxAge, +} \ No newline at end of file diff --git a/YahooFinanceApi/RowExtension.cs b/YahooFinanceApi/RowExtension.cs deleted file mode 100644 index 1f65153..0000000 --- a/YahooFinanceApi/RowExtension.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Globalization; - -namespace YahooFinanceApi -{ - internal static class RowExtension - { - internal static bool IgnoreEmptyRows; - - internal static Candle ToCandle(string[] row) - { - var candle = new Candle - { - DateTime = row[0].ToDateTime(), - Open = row[1].ToDecimal(), - High = row[2].ToDecimal(), - Low = row[3].ToDecimal(), - Close = row[4].ToDecimal(), - AdjustedClose = row[5].ToDecimal(), - Volume = row[6].ToInt64() - }; - - if (IgnoreEmptyRows && - candle.Open == 0 && candle.High == 0 && candle.Low == 0 && candle.Close == 0 && - candle.AdjustedClose == 0 && candle.Volume == 0) - return null; - - return candle; - } - - internal static DividendTick ToDividendTick(string[] row) - { - var tick = new DividendTick - { - DateTime = row[0].ToDateTime(), - Dividend = row[1].ToDecimal() - }; - - if (IgnoreEmptyRows && tick.Dividend == 0) - return null; - - return tick; - } - - internal static SplitTick ToSplitTick(string[] row) - { - var tick = new SplitTick { DateTime = row[0].ToDateTime() }; - - var split = row[1].Split(':'); - if (split.Length == 2) - { - tick.BeforeSplit = split[0].ToDecimal(); - tick.AfterSplit = split[1].ToDecimal(); - } - - if (IgnoreEmptyRows && tick.AfterSplit == 0 && tick.BeforeSplit == 0) - return null; - - return tick; - } - - private static DateTime ToDateTime(this string str) - { - if (!DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dt)) - throw new Exception($"Could not convert '{str}' to DateTime."); - return dt; - } - - private static Decimal ToDecimal(this string str) - { - Decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out Decimal result); - return result; - } - - private static Int64 ToInt64(this string str) - { - Int64.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out Int64 result); - return result; - } - } -} diff --git a/YahooFinanceApi/SecurityProfile.cs b/YahooFinanceApi/SecurityProfile.cs new file mode 100644 index 0000000..1574011 --- /dev/null +++ b/YahooFinanceApi/SecurityProfile.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace YahooFinanceApi; + +public class SecurityProfile +{ + public IReadOnlyDictionary Fields { get; private set; } + + // ctor + internal SecurityProfile(IReadOnlyDictionary fields) => Fields = fields; + + public dynamic this[string fieldName] => Fields[fieldName]; + public dynamic this[ProfileFields field] => Fields[field.ToString()]; + + public string Address1 => this[ProfileFields.Address1]; + public string City => this[ProfileFields.City]; + public string State => this[ProfileFields.State]; + public string Zip => this[ProfileFields.Zip]; + public string Country => this[ProfileFields.Country]; + public string Phone => this[ProfileFields.Phone]; + public string Website => this[ProfileFields.Website]; + public string Industry => this[ProfileFields.Industry]; + public string IndustryKey => this[ProfileFields.IndustryKey]; + public string IndustryDisp => this[ProfileFields.IndustryDisp]; + public string Sector => this[ProfileFields.Sector]; + public string SectorKey => this[ProfileFields.SectorKey]; + public string SectorDisp => this[ProfileFields.SectorDisp]; + public string LongBusinessSummary => this[ProfileFields.LongBusinessSummary]; + public long FullTimeEmployees => this[ProfileFields.FullTimeEmployees]; + public List CompanyOfficers => this[ProfileFields.CompanyOfficers]; + public long AuditRisk => this[ProfileFields.AuditRisk]; + public long BoardRisk => this[ProfileFields.BoardRisk]; + public long CompensationRisk => this[ProfileFields.CompensationRisk]; + public long ShareHolderRightsRisk => this[ProfileFields.ShareHolderRightsRisk]; + public long OverallRisk => this[ProfileFields.OverallRisk]; + public DateTime GovernanceEpochDate => DateTimeOffset.FromUnixTimeSeconds((long)this[ProfileFields.GovernanceEpochDate]).LocalDateTime; + public DateTime CompensationAsOfEpochDate => DateTimeOffset.FromUnixTimeSeconds((long)this[ProfileFields.CompensationAsOfEpochDate]).LocalDateTime; + public long MaxAge => this[ProfileFields.MaxAge]; +} \ No newline at end of file diff --git a/YahooFinanceApi/SplitTick.cs b/YahooFinanceApi/SplitTick.cs index a6c2702..3354878 100644 --- a/YahooFinanceApi/SplitTick.cs +++ b/YahooFinanceApi/SplitTick.cs @@ -4,6 +4,13 @@ namespace YahooFinanceApi { public sealed class SplitTick : ITick { + internal SplitTick(DateTime dateTime, decimal beforeSplit, decimal afterSplit) + { + DateTime = dateTime; + BeforeSplit = beforeSplit; + AfterSplit = afterSplit; + } + public DateTime DateTime { get; internal set; } public decimal BeforeSplit { get; internal set; } diff --git a/YahooFinanceApi/UtilityExtensions.cs b/YahooFinanceApi/UtilityExtensions.cs new file mode 100644 index 0000000..6fdf37d --- /dev/null +++ b/YahooFinanceApi/UtilityExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace YahooFinanceApi; + +public static class UtilityExtensions +{ + public static TValue GetValueOrNull(this IDictionary dictionary, TKey key) + where TValue : class + { + return dictionary.TryGetValue(key, out TValue value) ? value : null; + } + + public static Nullable GetValueOrNull(this IDictionary> dictionary, TKey key) + where TValue : struct + { + return dictionary.TryGetValue(key, out var value) ? value : null; + } + + public static TValue GetValueOrDefault(this IDictionary dictionary, TKey key) + { + return dictionary.TryGetValue(key, out TValue value) ? value : default; + } +} \ No newline at end of file diff --git a/YahooFinanceApi/Yahoo - Historical.cs b/YahooFinanceApi/Yahoo - Historical.cs index 9a71347..94104f0 100644 --- a/YahooFinanceApi/Yahoo - Historical.cs +++ b/YahooFinanceApi/Yahoo - Historical.cs @@ -8,123 +8,92 @@ using System.Threading.Tasks; using System.Net; using System.Diagnostics; +using System.Dynamic; using System.Globalization; +using System.Linq; -namespace YahooFinanceApi +namespace YahooFinanceApi; + +public sealed partial class Yahoo { - public sealed partial class Yahoo + public static CultureInfo Culture = CultureInfo.InvariantCulture; + public static bool IgnoreEmptyRows { set { DataConvertors.IgnoreEmptyRows = value; } } + + public static async Task> GetHistoricalAsync(string symbol, DateTime? startTime = null, DateTime? endTime = null, Period period = Period.Daily, CancellationToken token = default) + => await GetTicksAsync(symbol, + startTime, + endTime, + period, + ShowOption.History, + DataConvertors.ToCandle, + token).ConfigureAwait(false); + + public static async Task> GetDividendsAsync(string symbol, DateTime? startTime = null, DateTime? endTime = null, CancellationToken token = default) + => await GetTicksAsync(symbol, + startTime, + endTime, + Period.Daily, + ShowOption.Dividend, + DataConvertors.ToDividendTick, + token).ConfigureAwait(false); + + public static async Task> GetSplitsAsync(string symbol, DateTime? startTime = null, DateTime? endTime = null, CancellationToken token = default) + => await GetTicksAsync(symbol, + startTime, + endTime, + Period.Daily, + ShowOption.Split, + DataConvertors.ToSplitTick, + token).ConfigureAwait(false); + + private static async Task> GetTicksAsync + ( + string symbol, + DateTime? startTime, + DateTime? endTime, + Period period, + ShowOption showOption, + Func> converter, + CancellationToken token + ) + where T : ITick { - public static CultureInfo Culture = CultureInfo.InvariantCulture; - public static bool IgnoreEmptyRows { set { RowExtension.IgnoreEmptyRows = value; } } - - public static async Task> GetHistoricalAsync(string symbol, DateTime? startTime = null, DateTime? endTime = null, Period period = Period.Daily, CancellationToken token = default) - => await GetTicksAsync(symbol, - startTime, - endTime, - period, - ShowOption.History, - RowExtension.ToCandle, - token).ConfigureAwait(false); - - public static async Task> GetDividendsAsync(string symbol, DateTime? startTime = null, DateTime? endTime = null, CancellationToken token = default) - => await GetTicksAsync(symbol, - startTime, - endTime, - Period.Daily, - ShowOption.Dividend, - RowExtension.ToDividendTick, - token).ConfigureAwait(false); - - public static async Task> GetSplitsAsync(string symbol, DateTime? startTime = null, DateTime? endTime = null, CancellationToken token = default) - => await GetTicksAsync(symbol, - startTime, - endTime, - Period.Daily, - ShowOption.Split, - RowExtension.ToSplitTick, - token).ConfigureAwait(false); + await YahooSession.InitAsync(token); + TimeZoneInfo symbolTimeZone = await Cache.GetTimeZone(symbol); + + startTime ??= Helper.Epoch; + endTime ??= DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc); + DateTime start = startTime.Value.ToUtcFrom(symbolTimeZone); + DateTime end = endTime.Value.AddDays(2).ToUtcFrom(symbolTimeZone); + + dynamic json = await GetResponseStreamAsync(symbol, start, end, period, showOption.Name(), token).ConfigureAwait(false); + dynamic data = json.chart.result[0]; + + List allData = converter(data, symbolTimeZone); + return allData.Where(x => x != null).Where(x => x.DateTime <= endTime.Value).ToList(); + } - private static async Task> GetTicksAsync( - string symbol, - DateTime? startTime, - DateTime? endTime, - Period period, - ShowOption showOption, - Func instanceFunction, - CancellationToken token - ) + private static async Task GetResponseStreamAsync(string symbol, DateTime startTime, DateTime endTime, Period period, string events, CancellationToken token) + { + bool reset = false; + while (true) { - using (var stream = await GetResponseStreamAsync(symbol, startTime, endTime, period, showOption.Name(), token).ConfigureAwait(false)) - using (var sr = new StreamReader(stream)) - using (var csvReader = new CsvReader(sr, Culture)) - { - csvReader.Read(); // skip header - - var ticks = new List(); - - while (csvReader.Read()) - { - var tick = instanceFunction(csvReader.Context.Parser.Record); -#pragma warning disable RECS0017 // Possible compare of value type with 'null' - if (tick != null) -#pragma warning restore RECS0017 // Possible compare of value type with 'null' - ticks.Add(tick); - } - - return ticks; + try + { + return await ChartDataLoader.GetResponseStreamAsync(symbol, startTime, endTime, period, events, token).ConfigureAwait(false); } - } - - private static async Task GetResponseStreamAsync(string symbol, DateTime? startTime, DateTime? endTime, Period period, string events, CancellationToken token) - { - bool reset = false; - while (true) + catch (FlurlHttpException ex) when (ex.Call.Response?.StatusCode == (int)HttpStatusCode.NotFound) { - try - { - await YahooSession.InitAsync(token); - return await _GetResponseStreamAsync(token).ConfigureAwait(false); - } - catch (FlurlHttpException ex) when (ex.Call.Response?.StatusCode == (int)HttpStatusCode.NotFound) - { - throw new Exception($"Invalid ticker or endpoint for symbol '{symbol}'.", ex); - } - catch (FlurlHttpException ex) when (ex.Call.Response?.StatusCode == (int)HttpStatusCode.Unauthorized) - { - Debug.WriteLine("GetResponseStreamAsync: Unauthorized."); - - if (reset) - throw; - reset = true; // try again with a new client - } + throw new Exception($"Invalid ticker or endpoint for symbol '{symbol}'.", ex); } - - #region Local Functions - - Task _GetResponseStreamAsync(CancellationToken token) + catch (FlurlHttpException ex) when (ex.Call.Response?.StatusCode == (int)HttpStatusCode.Unauthorized) { - // Yahoo expects dates to be "Eastern Standard Time" - startTime = startTime?.FromEstToUtc() ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - endTime = endTime?.FromEstToUtc() ?? DateTime.UtcNow; - - var url = "https://query1.finance.yahoo.com/v7/finance/download" - .AppendPathSegment(symbol) - .SetQueryParam("period1", startTime.Value.ToUnixTimestamp()) - .SetQueryParam("period2", endTime.Value.ToUnixTimestamp()) - .SetQueryParam("interval", $"1{period.Name()}") - .SetQueryParam("events", events) - .SetQueryParam("crumb", YahooSession.Crumb); + Debug.WriteLine("GetResponseStreamAsync: Unauthorized."); - Debug.WriteLine(url); - - return url - .WithCookie(YahooSession.Cookie.Name, YahooSession.Cookie.Value) - .WithHeader(YahooSession.UserAgentKey, YahooSession.UserAgentValue) - .GetAsync(token) - .ReceiveStream(); + if (reset) + throw; + reset = true; // try again with a new client } - - #endregion } } -} +} \ No newline at end of file diff --git a/YahooFinanceApi/Yahoo - Profile.cs b/YahooFinanceApi/Yahoo - Profile.cs new file mode 100644 index 0000000..5684378 --- /dev/null +++ b/YahooFinanceApi/Yahoo - Profile.cs @@ -0,0 +1,67 @@ +using Flurl; +using Flurl.Http; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace YahooFinanceApi +{ + public sealed partial class Yahoo + { + + public static async Task QueryProfileAsync(string symbol, CancellationToken token = default) + { + await YahooSession.InitAsync(token); + + var url = $"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{symbol}" + .SetQueryParam("modules", "assetProfile,convert_dates") + .SetQueryParam("crumb", YahooSession.Crumb); + + // Invalid symbols as part of a request are ignored by Yahoo. + // So the number of symbols returned may be less than requested. + // If there are no valid symbols, an exception is thrown by Flurl. + // This exception is caught (below) and an empty dictionary is returned. + // There seems to be no easy way to reliably identify changed symbols. + + dynamic data = null; + + try + { + data = await url + .WithCookie(YahooSession.Cookie.Name, YahooSession.Cookie.Value) + .WithHeader(YahooSession.UserAgentKey, YahooSession.UserAgentValue) + .GetAsync(token) + .ReceiveJson() + .ConfigureAwait(false); + } + catch (FlurlHttpException ex) + { + if (ex.Call.Response.StatusCode == (int)System.Net.HttpStatusCode.NotFound) + { + return null; + } + else + { + throw; + } + } + + var response = data.quoteSummary; + + var error = response.error; + if (error != null) + { + throw new InvalidDataException($"An error was returned by Yahoo: {error}"); + } + + var result = response.result[0].assetProfile; + var pascalDictionary = ((IDictionary) result).ToDictionary(x => x.Key.ToPascal(), x => x.Value); + + + return new SecurityProfile(pascalDictionary); + } + } +} diff --git a/YahooFinanceApi/YahooSession.cs b/YahooFinanceApi/YahooSession.cs index e3be422..82ccc49 100644 --- a/YahooFinanceApi/YahooSession.cs +++ b/YahooFinanceApi/YahooSession.cs @@ -1,107 +1,106 @@ using Flurl.Http; using System.Threading.Tasks; using System; +using System.Collections.Generic; using System.Threading; using System.Linq; -namespace YahooFinanceApi +namespace YahooFinanceApi; + +/// +/// Holds state for Yahoo HTTP calls +/// +internal static class YahooSession { + private static string _crumb; + private static FlurlCookie _cookie; + private static SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + private static Dictionary timeZoneCache = new Dictionary(); + /// - /// Holds state for Yahoo HTTP calls + /// The user agent key for HTTP Header /// - internal static class YahooSession - { - private static string _crumb; - private static FlurlCookie _cookie; - private static SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + public const string UserAgentKey = "User-Agent"; - /// - /// The user agent key for HTTP Header - /// - public const string UserAgentKey = "User-Agent"; + /// + /// The user agent value for HTTP Header + /// + public const string UserAgentValue = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0"; - /// - /// The user agent value for HTTP Header - /// - public const string UserAgentValue = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0"; + /// + /// Gets or sets the auth crumb. + /// + /// + /// The crumb. + /// + public static string Crumb + { + get + { + return _crumb; + } + } - /// - /// Gets or sets the auth crumb. - /// - /// - /// The crumb. - /// - public static string Crumb + /// + /// Gets or sets the auth cookie. + /// + /// + /// The cookie. + /// + public static FlurlCookie Cookie + { + get { - get - { - return _crumb; - } + return _cookie; } + } - /// - /// Gets or sets the auth cookie. - /// - /// - /// The cookie. - /// - public static FlurlCookie Cookie + /// + /// Initializes the session asynchronously. + /// + /// The cancelation token. + /// Failure to create client. + public static async Task InitAsync(CancellationToken token = default) + { + if (_crumb != null) { - get - { - return _cookie; - } + return; } - /// - /// Initializes the session asynchronously. - /// - /// The cancelation token. - /// Failure to create client. - public static async Task InitAsync(CancellationToken token = default) + await _semaphore.WaitAsync(token).ConfigureAwait(false); + try { - if (_crumb != null) + var response = await "https://fc.yahoo.com" + .AllowHttpStatus("404") + .AllowHttpStatus("500") + .AllowHttpStatus("502") + .WithHeader(UserAgentKey, UserAgentValue) + .GetAsync() + .ConfigureAwait(false); + + _cookie = response.Cookies.FirstOrDefault(c => c.Name == "A3"); + + if (_cookie == null) { - return; + throw new Exception("Failed to obtain Yahoo auth cookie."); } - - await _semaphore.WaitAsync(token).ConfigureAwait(false); - try + else { - var response = await "https://fc.yahoo.com" - .AllowHttpStatus("404") - .AllowHttpStatus("500") - .AllowHttpStatus("502") + _crumb = await "https://query1.finance.yahoo.com/v1/test/getcrumb" + .WithCookie(_cookie.Name, _cookie.Value) .WithHeader(UserAgentKey, UserAgentValue) - .GetAsync() - .ConfigureAwait(false); - - _cookie = response.Cookies.FirstOrDefault(c => c.Name == "A3"); + .GetAsync(token) + .ReceiveString(); - if (_cookie == null) + if (string.IsNullOrEmpty(_crumb)) { - throw new Exception("Failed to obtain Yahoo auth cookie."); - } - else - { - _cookie = response.Cookies[0]; - - _crumb = await "https://query1.finance.yahoo.com/v1/test/getcrumb" - .WithCookie(_cookie.Name, _cookie.Value) - .WithHeader(UserAgentKey, UserAgentValue) - .GetAsync(token) - .ReceiveString(); - - if (string.IsNullOrEmpty(_crumb)) - { - throw new Exception("Failed to retrieve Yahoo crumb."); - } + throw new Exception("Failed to retrieve Yahoo crumb."); } } - finally - { - _semaphore.Release(); - } + } + finally + { + _semaphore.Release(); } } -} +} \ No newline at end of file