Skip to content
Merged
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
24 changes: 22 additions & 2 deletions src/CsvToOfx.Core/Models/HeaderMap.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
using System.Collections.Generic;
using System.Linq;

namespace CsvToOfx.Core.Models;

public sealed record HeaderMap(
string Name,
IReadOnlyDictionary<string, CanonicalField> Columns
);
IReadOnlyDictionary<string, CanonicalField> Columns,
IReadOnlyCollection<CanonicalField>? RequiredFields = null)
{
public IEnumerable<CanonicalField> EffectiveRequiredFields =>
RequiredFields is { Count: > 0 } ? RequiredFields : Columns.Values.Distinct();

public bool TryGetColumnName(CanonicalField field, out string columnName)
{
foreach (var entry in Columns)
{
if (entry.Value != field)
continue;

columnName = entry.Key;
return true;
}

columnName = string.Empty;
return false;
}
}
16 changes: 14 additions & 2 deletions src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityIraHeaderMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ namespace CsvToOfx.Core.Parsing.HeaderMaps;

public static class FidelityIraHeaderMap
{
private static readonly CanonicalField[] RequiredFields =
{
CanonicalField.TradeDate,
CanonicalField.Action,
CanonicalField.Symbol,
CanonicalField.Description,
CanonicalField.Price,
CanonicalField.Quantity,
CanonicalField.Fees,
CanonicalField.Amount
};

public static HeaderMap Instance { get; } = new HeaderMap(
"Fidelity-IRA",
new Dictionary<string, CanonicalField>(StringComparer.OrdinalIgnoreCase)
Expand All @@ -23,6 +35,6 @@ public static class FidelityIraHeaderMap
["Amount ($)"] = CanonicalField.Amount,
["Cash Balance ($)"] = CanonicalField.CashBalance,
["Settlement Date"] = CanonicalField.SettlementDate
});
},
RequiredFields);
}

16 changes: 14 additions & 2 deletions src/CsvToOfx.Core/Parsing/HeaderMaps/FidelityTradingHeaderMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ namespace CsvToOfx.Core.Parsing.HeaderMaps;

public static class FidelityTradingHeaderMap
{
private static readonly CanonicalField[] RequiredFields =
{
CanonicalField.TradeDate,
CanonicalField.Action,
CanonicalField.Symbol,
CanonicalField.Description,
CanonicalField.Price,
CanonicalField.Quantity,
CanonicalField.Fees,
CanonicalField.Amount
};

public static HeaderMap Instance { get; } = new HeaderMap(
"Fidelity-Trading",
new Dictionary<string, CanonicalField>(StringComparer.OrdinalIgnoreCase)
Expand All @@ -27,6 +39,6 @@ public static class FidelityTradingHeaderMap
["Amount"] = CanonicalField.Amount,
["Cash Balance"] = CanonicalField.CashBalance,
["Settlement Date"] = CanonicalField.SettlementDate
});
},
RequiredFields);
}

67 changes: 49 additions & 18 deletions src/CsvToOfx.Parsers/Providers/Fidelity/FidelityParser.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CsvToOfx.Core.Models;
using CsvToOfx.Core.Parsing.HeaderMaps;
using CsvToOfx.Parsers.Abstractions;
using CsvToOfx.Parsers.Shared;

Expand All @@ -10,8 +11,10 @@ public sealed class FidelityParser : IStatementParser
public string ProviderCode => "fidelity";
public ParserCapabilities Capabilities => ParserCapabilities.Csv | ParserCapabilities.Brokerage;

private static readonly string[] RequiredFields = {
"Run Date","Action","Symbol","Description","Type","Price","Quantity","Commission","Fees","Amount"
private static readonly HeaderMap[] HeaderMaps =
{
FidelityTradingHeaderMap.Instance,
FidelityIraHeaderMap.Instance
};

public ParseResult Parse(RawStatement input, ParserContext ctx)
Expand All @@ -20,19 +23,24 @@ public ParseResult Parse(RawStatement input, ParserContext ctx)
var account = new AccountRef("Fidelity", ctx.AccountId, "Brokerage");
var transactions = new List<NormalizedTransaction>();
var securities = new Dictionary<string, SecurityRef>(StringComparer.OrdinalIgnoreCase);
var readResult = reader.ReadRows(input.Content, HeaderMaps);
if (readResult is null)
return new ParseResult(account, transactions, securities.Values.ToList());

var columnsByField = BuildFieldLookup(readResult.HeaderMap);

foreach (var row in reader.ReadRows(input.Content, RequiredFields))
foreach (var row in readResult.Rows)
{
// skip empty rows
var nonEmpty = row.Values.Count(v => !string.IsNullOrWhiteSpace(v));
if (nonEmpty <= 1) continue;

// date filter
var dt = ctx.DateParser.ParseOrNull(Get(row, "Run Date"));
var dt = ctx.DateParser.ParseOrNull(Get(row, columnsByField, CanonicalField.TradeDate));
if (ctx.StartDateFilter.HasValue && dt.HasValue && dt.Value < ctx.StartDateFilter.Value) continue;

var action = FidelityActionMap.Normalize(Get(row, "Action"));
var symbol = (Get(row, "Symbol") ?? "").Trim();
var action = FidelityActionMap.Normalize(Get(row, columnsByField, CanonicalField.Action));
var symbol = (Get(row, columnsByField, CanonicalField.Symbol) ?? "").Trim();
var security = ctx.SecurityResolver.ResolveFromRow(row);
if (security is not null)
{
Expand All @@ -55,12 +63,12 @@ public ParseResult Parse(RawStatement input, ParserContext ctx)
}
}

var units = TryParseDecimal(Get(row, "Quantity"));
var unitPrice = TryParseDecimal(Get(row, "Price"));
var amount = ctx.AmountParser.ParseAbsOrNull(Get(row, "Amount")) ?? 0m;
var memo = BuildMemo(action, row);
var fees = TryParseDecimal(Get(row, "Fees"));
var currency = (Get(row, "Currency") ?? ctx.CurrencyDefault).Trim();
var units = TryParseDecimal(Get(row, columnsByField, CanonicalField.Quantity));
var unitPrice = TryParseDecimal(Get(row, columnsByField, CanonicalField.Price));
var amount = ctx.AmountParser.ParseAbsOrNull(Get(row, columnsByField, CanonicalField.Amount)) ?? 0m;
var memo = BuildMemo(action, row, columnsByField);
var fees = TryParseDecimal(Get(row, columnsByField, CanonicalField.Fees));
var currency = (Get(row, columnsByField, CanonicalField.Currency) ?? ctx.CurrencyDefault).Trim();
var fitid = ctx.FitIdGenerator.FromSortedRow(row);

transactions.Add(new NormalizedTransaction(
Expand All @@ -79,22 +87,45 @@ public ParseResult Parse(RawStatement input, ParserContext ctx)
return new ParseResult(account, transactions, securities.Values.ToList());
}

private static string? Get(IDictionary<string, string?> row, string key)
=> row.TryGetValue(key, out var value) ? value : null;
private static Dictionary<CanonicalField, string> BuildFieldLookup(HeaderMap headerMap)
{
var lookup = new Dictionary<CanonicalField, string>();
foreach (var field in headerMap.Columns.Values.Distinct())
{
if (headerMap.TryGetColumnName(field, out var columnName))
lookup[field] = columnName;
}

return lookup;
}

private static string? Get(
IDictionary<string, string?> row,
IReadOnlyDictionary<CanonicalField, string> columnsByField,
CanonicalField field)
{
if (!columnsByField.TryGetValue(field, out var columnName))
return null;

return row.TryGetValue(columnName, out var value) ? value : null;
}

private static decimal? TryParseDecimal(string? s)
=> decimal.TryParse((s ?? "").Replace(",", ""), out var v) ? v : null;

private static string? BuildMemo(CanonicalAction action, IDictionary<string, string?> row)
private static string? BuildMemo(
CanonicalAction action,
IDictionary<string, string?> row,
IReadOnlyDictionary<CanonicalField, string> columnsByField)
{
var symbol = (Get(row, "Symbol") ?? "").Trim();
var symbol = (Get(row, columnsByField, CanonicalField.Symbol) ?? "").Trim();
return action switch
{
CanonicalAction.Income => string.IsNullOrWhiteSpace(symbol) ? "Dividend Received" : $"Dividend Received - {symbol}",
CanonicalAction.CashTransfer => (Get(row, "Action") ?? "").Trim(),
CanonicalAction.CashTransfer => (Get(row, columnsByField, CanonicalField.Action) ?? "").Trim(),
CanonicalAction.BuyStock => string.IsNullOrWhiteSpace(symbol) ? "You Bought" : $"You Bought - {symbol}",
CanonicalAction.SellStock => string.IsNullOrWhiteSpace(symbol) ? "You Sold" : $"You Sold - {symbol}",
_ => Get(row, "Description")
_ => Get(row, columnsByField, CanonicalField.Description)
};
}
}
8 changes: 8 additions & 0 deletions src/CsvToOfx.Parsers/Shared/CsvReadResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CsvToOfx.Core.Models;

namespace CsvToOfx.Parsers.Shared;

public sealed record CsvReadResult(
HeaderMap HeaderMap,
IReadOnlyList<IDictionary<string, string?>> Rows
);
85 changes: 85 additions & 0 deletions src/CsvToOfx.Parsers/Shared/CsvRowReader.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CsvHelper;
using CsvHelper.Configuration;
using CsvToOfx.Core.Models;
using System.Globalization;

namespace CsvToOfx.Parsers.Shared;
Expand Down Expand Up @@ -62,6 +63,56 @@ public sealed class CsvRowReader
}
}

public CsvReadResult? ReadRows(Stream csv, IEnumerable<HeaderMap> headerMaps)
{
using var reader = new StreamReader(csv);
using var csvr = new CsvReader(reader, _conf);

var candidates = headerMaps.ToList();
HeaderMap? matchedHeaderMap = null;
string[]? headerRecord = null;
var rows = new List<IDictionary<string, string?>>();
var seenData = false;

while (csvr.Read())
{
var record = csvr.Parser.Record;
if (record is null || record.Length == 0)
continue;

var nonEmpty = record.Count(v => !string.IsNullOrWhiteSpace(v));
if (nonEmpty == 0)
{
if (seenData)
break;

continue;
}

if (matchedHeaderMap is null)
{
matchedHeaderMap = MatchHeader(record, candidates);
if (matchedHeaderMap is null)
continue;

headerRecord = record.Select(v => v?.Trim() ?? string.Empty).ToArray();
continue;
}

if (headerRecord is null || record.Length < headerRecord.Length)
continue;

var converted = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < headerRecord.Length; i++)
converted[headerRecord[i]] = i < record.Length ? record[i] : null;

seenData = true;
rows.Add(converted);
}

return matchedHeaderMap is null ? null : new CsvReadResult(matchedHeaderMap, rows);
}

private static bool MatchesHeader(string[] record, HashSet<string>? requiredHeaders)
{
var normalized = record
Expand All @@ -77,4 +128,38 @@ private static bool MatchesHeader(string[] record, HashSet<string>? requiredHead

return requiredHeaders.IsSubsetOf(normalized);
}

private static HeaderMap? MatchHeader(string[] record, IReadOnlyCollection<HeaderMap> headerMaps)
{
var normalized = record
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);

if (normalized.Count == 0)
return null;

HeaderMap? bestMatch = null;
var bestScore = -1;

foreach (var headerMap in headerMaps)
{
var matchedFields = headerMap.Columns
.Where(entry => normalized.Contains(entry.Key))
.Select(entry => entry.Value)
.ToHashSet();

if (!headerMap.EffectiveRequiredFields.All(matchedFields.Contains))
continue;

var score = matchedFields.Count;
if (score <= bestScore)
continue;

bestMatch = headerMap;
bestScore = score;
}

return bestMatch;
}
}
Loading
Loading