Skip to content
Open
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,14 @@ export, then creates a summary of your sales and purchases for the tax year.

4. Save the XLSX file into directory called 'dividends'

# Obtaining Interactive Brokers Dividend CSV export

1. Go to Transaction & History -> Transaction History
2. Select "Custom" and From Date and To Date
3. Click on "CSV" icon and download the file
4. Save the CSV file into directory called 'ib/dividends'

![](media/ib_transactions.png)


# Running the application
Expand Down
Binary file added media/ib_transactions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 25 additions & 5 deletions src/main/kotlin/cz/solutions/cockroach/CockroachMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@ fun main(args: Array<String>) {
System.err.println(" espp/ - ESPP purchase confirmation PDFs")
System.err.println(" dividends/ - single dividends XLSX file")
System.err.println(" sales/ - single Gain & Loss CSV file")
System.err.println(" ib-dir Optional Interactive Brokers data directory with subdirs:")
System.err.println(" dividends/ - single dividends CSV file")

System.exit(1)
}
val eTradeDir = if (args.size > 3) File(args[3]) else null
CockroachMain.report(File(args[0]), args[1].toInt(), File(args[2]), eTradeDir)
val ibDir = if (args.size > 4) File(args[4]) else null
CockroachMain.report(File(args[0]), args[1].toInt(), File(args[2]), eTradeDir, ibDir)
}

object CockroachMain {
private val LOGGER = Logger.getLogger(CockroachMain::class.java.name)

fun report(schwabExportFile: File, year: Int, outputDir: File, eTradeDir: File? = null) {
fun report(schwabExportFile: File, year: Int, outputDir: File, eTradeDir: File? = null, ibDir: File? = null) {
val schwabExport = parseExportFile(schwabExportFile)
val eTradeExport = eTradeDir?.let { parseETradeDir(it) } ?: ParsedExport.empty()
val parsedExport = schwabExport + eTradeExport
val ibExport = ibDir?.let { parseIBDir(it) } ?: ParsedExport.empty()
val parsedExport = schwabExport + eTradeExport + ibExport
val fixedRateReport = ReportGenerator.generateForYear(parsedExport, year, YearConstantExchangeRateProvider.hardcoded())
val dynamicRateReport = ReportGenerator.generateForYear(parsedExport, year, TabularExchangeRateProvider.hardcoded())

Expand Down Expand Up @@ -67,8 +72,8 @@ object CockroachMain {
private fun parseETradeDir(eTradeDir: File): ParsedExport {
val rsuRecords = RsuPdfParser.parseDirectory(File(eTradeDir, "rsu"))
val esppRecords = EsppPdfParser.parseDirectory(File(eTradeDir, "espp"))
val dividentXlsFile = locateSingleFile(File(eTradeDir, "dividends"), "xlsx")
val dividendXlsxResult = dividentXlsFile?.let { DividendXlsxParser.parse(it)}
val dividendXlsFile = locateSingleFile(File(eTradeDir, "dividends"), "xlsx")
val dividendXlsxResult = dividendXlsFile?.let { DividendXlsxParser.parse(it)}
val eTradeXlsFile = locateSingleFile(File(eTradeDir, "sales"), "xlsx")
val eTradeCsvFile = locateSingleFile(File(eTradeDir, "sales"), "csv")

Expand All @@ -85,6 +90,21 @@ object CockroachMain {
)
}

private fun parseIBDir(ibDir: File): ParsedExport {
val dividendCsvFile = locateSingleFile(File(ibDir, "dividends"), "csv")
val dividendCsvResult = dividendCsvFile?.let { DividendIBParser.parse(it)}

return ParsedExport(
rsuRecords = emptyList(),
esppRecords = emptyList(),
saleRecords = emptyList(),
dividendRecords = dividendCsvResult?.dividendRecords?: emptyList(),
taxRecords = dividendCsvResult?.taxRecords?:emptyList(),
taxReversalRecords = emptyList(),
journalRecords = emptyList()
)
}

private fun locateSingleFile(directory: File, extension: String): File? {
if (!directory.exists()){
return null
Expand Down
51 changes: 51 additions & 0 deletions src/main/kotlin/cz/solutions/cockroach/DividendIBParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cz.solutions.cockroach

import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import org.apache.commons.csv.CSVRecord
import org.joda.time.LocalDate
import org.joda.time.format.DateTimeFormat
import java.io.File
import java.io.FileInputStream
import java.io.InputStreamReader

object DividendIBParser {

private val DATE_FORMATTER = DateTimeFormat.forPattern("yyyy-MM-dd")

fun parse(file: File): DividendXlsxResult {
return file.inputStream().use { parse(it) }
}

fun parse(file: FileInputStream): DividendXlsxResult {
val dividends = mutableListOf<DividendRecord>()
val taxes = mutableListOf<TaxRecord>()

val format = CSVFormat.Builder.create()
.setDelimiter(',')
.build()

InputStreamReader(file).use { reader ->
CSVParser(reader, format).use { parser ->
parser.records
.filter { it.hasValueAt(0, "Transaction History") && it.hasValueAt(1, "Data") }
.forEach { row ->
val transactionType = row.get(5).trim()
val date = LocalDate.parse(row.get(2).trim(), DATE_FORMATTER)
val amount = row.get(12).trim().toDouble()

when (transactionType) {
"Dividend" -> dividends.add(DividendRecord(date, amount))
"Foreign Tax Withholding" -> taxes.add(TaxRecord(date, amount))
}
}
}
}

return DividendXlsxResult(dividends, taxes)
}

private fun CSVRecord.hasValueAt(index: Int, value: String): Boolean {
return size() > index && get(index).trim() == value
}
}
39 changes: 39 additions & 0 deletions src/test/kotlin/cz/solutions/cockroach/DividendIBParserTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package cz.solutions.cockroach

import org.assertj.core.api.Assertions.assertThat
import org.joda.time.LocalDate
import org.junit.jupiter.api.Test
import java.io.File

class DividendIBParserTest {

private fun loadResourceAsFile(name: String): File {
return File({}::class.java.getResource(name)!!.toURI())
}

@Test
fun parsesDividendAndTaxRecordsFromIbCsv() {
val file = loadResourceAsFile("ib_dividend.csv")

val result = DividendIBParser.parse(file)

assertThat(result.dividendRecords).containsExactly(
DividendRecord(LocalDate(2025, 12, 1), 10.0),
DividendRecord(LocalDate(2025, 11, 13), 20.0)
)
assertThat(result.taxRecords).containsExactly(
TaxRecord(LocalDate(2025, 12, 1), -1.5),
TaxRecord(LocalDate(2025, 11, 13), -3.0)
)
}

@Test
fun parsesRecordCountFromIbCsv() {
val file = loadResourceAsFile("ib_dividend.csv")

val result = DividendIBParser.parse(file)

assertThat(result.dividendRecords).hasSize(2)
assertThat(result.taxRecords).hasSize(2)
}
}
14 changes: 14 additions & 0 deletions src/test/resources/cz/solutions/cockroach/ib_dividend.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Statement,Header,Field Name,Field Value
Statement,Data,Title,Transaction History
Statement,Data,Period,January 1, 2025 - December 31, 2025
Statement,Data,WhenGenerated,2026-04-28, 16:03:12 EDT
Summary,Header,Field Name,Field Value
Summary,Data,Base Currency,USD
Summary,Data,Starting Cash,0.0
Summary,Data,Change,25.5
Summary,Data,Ending Cash,25.5
Transaction History,Header,Date,Account,Description,Transaction Type,Symbol,Quantity,Price,Price Currency,Gross Amount ,Commission,Net Amount
Transaction History,Data,2025-12-01,U***61329,DIV1,Dividend,DIV1,-,-,-,10.0,-,10.0
Transaction History,Data,2025-12-01,U***61329,DIV1,Foreign Tax Withholding,DIV1,-,-,-,-1.5,-,-1.5
Transaction History,Data,2025-11-13,U***61329,DIV2,Dividend,DIV2,-,-,-,20.0,-,20.0
Transaction History,Data,2025-11-13,U***61329,DIV2,Foreign Tax Withholding,DIV2,-,-,-,-3.0,-,-3.0