-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge transactions on the same day (#38)
- Loading branch information
Showing
5 changed files
with
228 additions
and
67 deletions.
There are no files selected for viewing
78 changes: 78 additions & 0 deletions
78
GhostfolioSidekick.UnitTests/Ghostfolio/Contract/ActivityTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
using AutoFixture; | ||
using FluentAssertions; | ||
using GhostfolioSidekick.Ghostfolio.API.Contract; | ||
|
||
namespace GhostfolioSidekick.UnitTests.Ghostfolio.Contract | ||
{ | ||
public class ActivityTests | ||
{ | ||
[Fact] | ||
public void MergeTwoBuys_CorrectlyMerged() | ||
{ | ||
// Arrange | ||
var asset = new Fixture().Create<Asset>(); | ||
var a = new Activity { Asset = asset, Type = ActivityType.BUY, Quantity = 2, Fee = 1, UnitPrice = 100 }; | ||
var b = new Activity { Asset = asset, Type = ActivityType.BUY, Quantity = 2, Fee = 3, UnitPrice = 10 }; | ||
|
||
// Act | ||
var c = a.Merge(b); | ||
|
||
// Assert | ||
c.Type.Should().Be(ActivityType.BUY); | ||
c.Quantity.Should().Be(4); | ||
c.Fee.Should().Be(4); | ||
c.UnitPrice.Should().Be(55); | ||
} | ||
|
||
[Fact] | ||
public void MergeTwoSell_CorrectlyMerged() | ||
{ | ||
// Arrange | ||
var asset = new Fixture().Create<Asset>(); | ||
var a = new Activity { Asset = asset, Type = ActivityType.SELL, Quantity = 2, Fee = 1, UnitPrice = 100 }; | ||
var b = new Activity { Asset = asset, Type = ActivityType.SELL, Quantity = 2, Fee = 3, UnitPrice = 10 }; | ||
|
||
// Act | ||
var c = a.Merge(b); | ||
|
||
// Assert | ||
c.Type.Should().Be(ActivityType.SELL); | ||
c.Quantity.Should().Be(4); | ||
c.Fee.Should().Be(4); | ||
c.UnitPrice.Should().Be(55); | ||
} | ||
|
||
[Fact] | ||
public void MergeBuyAndSell_CorrectlyMerged() | ||
{ | ||
// Arrange | ||
var asset = new Fixture().Create<Asset>(); | ||
var a = new Activity { Asset = asset, Type = ActivityType.BUY, Quantity = 3, Fee = 1, UnitPrice = 50 }; | ||
var b = new Activity { Asset = asset, Type = ActivityType.SELL, Quantity = 1, Fee = 1, UnitPrice = 100 }; | ||
|
||
// Act | ||
var c = a.Merge(b); | ||
|
||
// Assert | ||
c.Type.Should().Be(ActivityType.BUY); | ||
c.Quantity.Should().Be(2); | ||
c.Fee.Should().Be(2); | ||
c.UnitPrice.Should().Be(62.5M); | ||
} | ||
|
||
[Fact] | ||
public void MergeBuyAndSell_EqualAmount_SetToIgnore() | ||
{ | ||
// Arrange | ||
var asset = new Fixture().Create<Asset>(); | ||
var a = new Activity { Asset = asset, Type = ActivityType.BUY, Quantity = 3, Fee = 1, UnitPrice = 50 }; | ||
var b = new Activity { Asset = asset, Type = ActivityType.SELL, Quantity = 3, Fee = 1, UnitPrice = 100 }; | ||
|
||
// Act | ||
var c = a.Merge(b); | ||
|
||
// Assert | ||
c.Type.Should().Be(ActivityType.IGNORE); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,29 +1,91 @@ | ||
namespace GhostfolioSidekick.Ghostfolio.API.Contract | ||
{ | ||
public class Activity | ||
{ | ||
public string AccountId { get; set; } | ||
public class Activity | ||
{ | ||
public string AccountId { get; set; } | ||
|
||
public Asset Asset { get; set; } | ||
public Asset Asset { get; set; } | ||
|
||
public string Comment { get; set; } | ||
public string Comment { get; set; } | ||
|
||
public string Currency { get; set; } | ||
public string Currency { get; set; } | ||
|
||
public DateTime Date { get; set; } | ||
public DateTime Date { get; set; } | ||
|
||
public decimal Fee { get; set; } | ||
public decimal Fee { get; set; } | ||
|
||
public string FeeCurrency { get; set; } | ||
public string FeeCurrency { get; set; } | ||
|
||
public decimal Quantity { get; set; } | ||
public decimal Quantity { get; set; } | ||
|
||
public ActivityType Type { get; set; } | ||
public ActivityType Type { get; set; } | ||
|
||
public decimal UnitPrice { get; set; } | ||
public decimal UnitPrice { get; set; } | ||
|
||
|
||
// Internal use | ||
public string ReferenceCode { get; set; } | ||
} | ||
// Internal use | ||
public string ReferenceCode { get; set; } | ||
|
||
public Activity Merge(Activity activity) | ||
{ | ||
if (activity == null) throw new ArgumentNullException(); | ||
if (Type == ActivityType.IGNORE) | ||
{ | ||
return this; | ||
} | ||
|
||
var canJoin = | ||
(Type == ActivityType.BUY || Type == ActivityType.SELL) && | ||
(activity.Type == ActivityType.BUY || activity.Type == ActivityType.SELL) && | ||
AccountId == activity.AccountId && | ||
Asset.Symbol == activity.Asset.Symbol && | ||
Currency == activity.Currency && | ||
FeeCurrency == activity.FeeCurrency; | ||
|
||
if (!canJoin) | ||
{ | ||
throw new NotSupportedException(); | ||
} | ||
|
||
var positiveOrNegativeThis = Type == ActivityType.BUY ? 1 : -1; | ||
var positiveOrNegativeOther = activity.Type == ActivityType.BUY ? 1 : -1; | ||
|
||
decimal totalQuantity = Quantity + activity.Quantity; | ||
var unitPrice = totalQuantity == 0 ? 0 : | ||
((UnitPrice * Quantity) + (activity.UnitPrice * activity.Quantity)) | ||
/ | ||
totalQuantity; | ||
|
||
decimal quantity = positiveOrNegativeThis * Quantity + positiveOrNegativeOther * activity.Quantity; | ||
|
||
ActivityType activityType; | ||
if (quantity == 0) | ||
{ | ||
activityType = ActivityType.IGNORE; | ||
} | ||
else if (quantity > 0) | ||
{ | ||
activityType = ActivityType.BUY; | ||
} | ||
else | ||
{ | ||
activityType = ActivityType.SELL; | ||
} | ||
|
||
return new Activity | ||
{ | ||
Type = activityType, | ||
AccountId = AccountId, | ||
Asset = Asset, | ||
Comment = Comment, | ||
Currency = Currency, | ||
FeeCurrency = FeeCurrency, | ||
Date = Date, | ||
Fee = Fee + activity.Fee, | ||
Quantity = Math.Abs(quantity), | ||
ReferenceCode = ReferenceCode, | ||
UnitPrice = Math.Abs(unitPrice) | ||
}; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,31 +1,53 @@ | ||
namespace GhostfolioSidekick.Ghostfolio | ||
using GhostfolioSidekick.Ghostfolio.API.Contract; | ||
|
||
namespace GhostfolioSidekick.Ghostfolio | ||
{ | ||
public class DateTimeCollisionFixer | ||
{ | ||
public static IEnumerable<Model.Activity> Fix(IEnumerable<Model.Activity> orders) | ||
public static IEnumerable<Activity> Merge(IEnumerable<Activity> activities) | ||
{ | ||
var mergeActivities = new[] { ActivityType.BUY, ActivityType.SELL }; | ||
var toMerge = activities.Where(x => mergeActivities.Contains(x.Type)).ToList(); | ||
|
||
return | ||
toMerge | ||
.GroupBy(x => Tuple.Create(x.Asset?.Symbol ?? string.Empty, x.Date.Date)) | ||
.Select(x => | ||
{ | ||
return MergeToOne(x.ToList()); | ||
|
||
}) | ||
.ToList() | ||
.Union(activities.Where(x => !mergeActivities.Contains(x.Type))); | ||
} | ||
|
||
private static Activity MergeToOne(List<Activity> activities) | ||
{ | ||
var isChecked = false; | ||
var sortedActivities = activities | ||
.OrderBy(x => x.Date) | ||
.ThenBy(x => x.ReferenceCode) | ||
.ThenBy(x => x.Quantity); | ||
|
||
var r = sortedActivities.First(); | ||
|
||
var updated = orders.ToList(); | ||
while (!isChecked) | ||
foreach (var activity in sortedActivities.Skip(1)) | ||
{ | ||
isChecked = true; | ||
r = r.Merge(activity); | ||
} | ||
|
||
updated = updated | ||
.GroupBy(x => new { x.Asset?.Symbol, x.Date }) | ||
.SelectMany(x => x.OrderBy(y => y.ReferenceCode).Select((y, i) => | ||
{ | ||
if (i > 0) | ||
{ | ||
isChecked = false; | ||
} | ||
|
||
y.Date = y.Date.AddSeconds(i); | ||
return y; | ||
})).ToList(); | ||
r.Comment = GenerateComment(sortedActivities); | ||
|
||
return r; | ||
} | ||
|
||
private static string GenerateComment(IOrderedEnumerable<Activity> sortedActivities) | ||
{ | ||
if (sortedActivities.Count() == 1) | ||
{ | ||
return sortedActivities.Single().Comment; | ||
} | ||
|
||
return updated; | ||
return sortedActivities.First().Comment + " (" + string.Join('|', sortedActivities.Select(x => $"{x.Date.ToShortTimeString()} {x.Type} {x.Quantity}@{x.UnitPrice}")) + ")"; | ||
} | ||
} | ||
} |