Skip to content

Commit

Permalink
Merge transactions on the same day (#38)
Browse files Browse the repository at this point in the history
  • Loading branch information
VibeNL authored Oct 11, 2023
1 parent 1ffb3d1 commit 9bf830c
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 67 deletions.
78 changes: 78 additions & 0 deletions GhostfolioSidekick.UnitTests/Ghostfolio/Contract/ActivityTests.cs
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentAssertions;
using GhostfolioSidekick.Ghostfolio;
using GhostfolioSidekick.Ghostfolio.API.Contract;

namespace GhostfolioSidekick.UnitTests.Ghostfolio
{
Expand All @@ -9,68 +10,65 @@ public class DateTimeCollisionFixerTests
public async Task NoCollisions()
{
// Arrange
var orders = new[] {
new Model.Activity{ ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)},
new Model.Activity{ ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,3, DateTimeKind.Utc)}
var activities = new[] {
new Activity{ ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Activity{ ReferenceCode = "2", Date = new DateTime(2023,1,2,1,1,1, DateTimeKind.Utc)},
new Activity{ ReferenceCode = "3", Date = new DateTime(2023,1,3,1,1,1, DateTimeKind.Utc)}
};

// Act
DateTimeCollisionFixer.Fix(orders);
activities = DateTimeCollisionFixer.Merge(activities).ToArray();

// Assert
orders.Should().BeEquivalentTo(new[]
activities.Should().BeEquivalentTo(new[]
{
new Model.Activity{ ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)},
new Model.Activity{ ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,3, DateTimeKind.Utc)}
new Activity{ ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Activity{ ReferenceCode = "2", Date = new DateTime(2023,1,2,1,1,1, DateTimeKind.Utc)},
new Activity{ ReferenceCode = "3", Date = new DateTime(2023,1,3,1,1,1, DateTimeKind.Utc)}
});
}

[Fact]
public async Task SingleCollisionsWithCascadingEffect()
public async Task SingleCollision_Merged()
{
// Arrange
var asset = new Model.Asset { Symbol = "A" };
var orders = new[] {
new Model.Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ Asset = asset, ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ Asset = asset, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)}
var asset = new Asset { Symbol = "A" };
var activities = new[] {
new Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc), Quantity = 1},
new Activity{ Asset = asset, ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc), Quantity = 1},
new Activity{ Asset = asset, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc), Quantity = 1}
};

// Act
DateTimeCollisionFixer.Fix(orders);
activities = DateTimeCollisionFixer.Merge(activities).ToArray();

// Assert
orders.Should().BeEquivalentTo(new[]
activities.Should().BeEquivalentTo(new[]
{
new Model.Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ Asset = asset, ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)},
new Model.Activity{Asset = asset, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,3, DateTimeKind.Utc)}
new Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc), Quantity = 3, Comment = " (01:01 BUY 1@0|01:01 BUY 1@0|01:01 BUY 1@0)"}
});
}

[Fact]
public async Task SingleCollisionsWithNoCascadingEffect()
public async Task SingleCollisions_MultipleAssets_CorrectlyMerged()
{
// Arrange
var asset = new Model.Asset { Symbol = "A" };
var assetB = new Model.Asset { Symbol = "B" };
var orders = new[] {
new Model.Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ Asset = asset, ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ Asset = assetB, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)}
var asset = new Asset { Symbol = "A" };
var assetB = new Asset { Symbol = "B" };
var activities = new[] {
new Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc), Quantity = 1},
new Activity{ Asset = asset, ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc), Quantity = 1},
new Activity{ Asset = assetB, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc), Quantity = 1}
};

// Act
DateTimeCollisionFixer.Fix(orders);
activities = DateTimeCollisionFixer.Merge(activities).ToArray();

// Assert
orders.Should().BeEquivalentTo(new[]
activities.Should().BeEquivalentTo(new[]
{
new Model.Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc)},
new Model.Activity{ Asset = asset, ReferenceCode = "2", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)},
new Model.Activity{Asset = assetB, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc)}
new Activity{ Asset = asset, ReferenceCode = "1", Date = new DateTime(2023,1,1,1,1,1, DateTimeKind.Utc), Quantity = 2, Comment=" (01:01 BUY 1@0|01:01 BUY 1@0)"},
new Activity{Asset = assetB, ReferenceCode = "3", Date = new DateTime(2023,1,1,1,1,2, DateTimeKind.Utc), Quantity = 1}
});
}
}
Expand Down
92 changes: 77 additions & 15 deletions GhostfolioSidekick/Ghostfolio/API/Contract/Activity.cs
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)
};
}
}
}
3 changes: 2 additions & 1 deletion GhostfolioSidekick/Ghostfolio/API/GhostfolioAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ public async Task UpdateAccount(Model.Account account)

await UpdateBalance(account, balance);

var newActivities = DateTimeCollisionFixer.Fix(account.Activities)
var newActivities = account.Activities
.Select(x => modelToContractMapper.ConvertToGhostfolioActivity(account, x))
.Where(x => x != null)
.Where(x => x.Type != Contract.ActivityType.IGNORE)
.ToList();
newActivities = DateTimeCollisionFixer.Merge(newActivities).ToList();

var content = await restCall.DoRestGet($"api/v1/order?accounts={existingAccount.Id}", CacheDuration.None());
var existingActivities = JsonConvert.DeserializeObject<RawActivityList>(content).Activities;
Expand Down
60 changes: 41 additions & 19 deletions GhostfolioSidekick/Ghostfolio/DateTimeCollisionFixer.cs
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}")) + ")";
}
}
}

0 comments on commit 9bf830c

Please sign in to comment.