///|
/// The kind of investment activity a record describes.
///
/// The variants cover the aggregates a retail brokerage statement actually
/// uses; a tag this library does not classify still produces a record, with
/// `Unknown` as its kind and the original tag kept in [`Investment::tag`], so
/// nothing is silently dropped.
pub enum InvestmentKind {
BuyStock
SellStock
Income
Reinvest
BankTransfer
Transfer
MarginInterest
Expense
Unknown
}
///|
/// `buy`, `sell`, `income`, `reinvest`, `transfer`, `bank-transfer`,
/// `margin-interest`, `expense` or `unknown`.
pub fn InvestmentKind::to_string(self : InvestmentKind) -> String {
match self {
BuyStock => "buy"
SellStock => "sell"
Income => "income"
Reinvest => "reinvest"
BankTransfer => "bank-transfer"
Transfer => "transfer"
MarginInterest => "margin-interest"
Expense => "expense"
Unknown => "unknown"
}
}
///|
/// Classify an investment aggregate name.
pub fn investment_kind_of(tag : String) -> InvestmentKind {
match tag {
"BUYSTOCK" | "BUYMF" | "BUYDEBT" | "BUYOPT" => BuyStock
"SELLSTOCK" | "SELLMF" | "SELLDEBT" | "SELLOPT" => SellStock
"INCOME" => Income
"REINVEST" => Reinvest
"INVBANKTRAN" => BankTransfer
"TRANSFER" | "JOURNAL" | "CLOSUREOPT" => Transfer
"MARGININTEREST" => MarginInterest
"INVEXPENSE" => Expense
_ => Unknown
}
}
///|
/// Whether a tag starts an investment record.
fn is_investment_record(tag : String) -> Bool {
!(investment_kind_of(tag) is Unknown)
}
///|
/// The aggregates that can appear inside an investment record.
///
/// They only matter for keeping the scope stack balanced, because a record is
/// closed by its own tag or by the next record that starts; a tag outside this
/// set is a leaf field.
fn is_investment_aggregate(tag : String) -> Bool {
match tag {
"INVSTMTMSGSRSV1"
| "INVSTMTTRNRS"
| "INVSTMTRS"
| "INVACCTFROM"
| "INVTRANLIST"
| "INVTRAN"
| "INVBUY"
| "INVSELL"
| "SECID"
| "SECLIST"
| "INVBAL"
| "INVPOS"
| "STMTTRN" => true
_ => false
}
}
///|
/// One investment transaction, flattened to the fields a ledger needs.
///
/// The nesting OFX uses (`INVTRAN` inside `INVBUY` inside `BUYSTOCK`, a
/// `STMTTRN` inside `INVBANKTRAN`) is collapsed here: `fitid`, `trade_date` and
/// `total` carry the same meaning whichever aggregate supplied them, and a
/// diagnostic still points at the line the value came from.
pub struct Investment {
kind : InvestmentKind
tag : String
fitid : String
trade_date : String
settle_date : String
security_id : String
security_id_type : String
units : String
unit_price : String
commission : String
fees : String
taxes : String
total : String
sub_account_sec : String
sub_account_fund : String
income_type : String
buy_type : String
sell_type : String
currency : String
name : String
memo : String
}
///|
/// The total of the record at a scale, or `None` when the document stated none
/// or it cannot be represented.
pub fn Investment::total_minor_units(self : Investment, scale : Int) -> Int64? {
if self.total == "" {
None
} else {
amount_to_minor_units(self.total, scale)
}
}
///|
/// The traded quantity at a scale, for a caller that stores share counts as
/// integers (`scale` 4 keeps four decimal places of a share).
pub fn Investment::units_at_scale(self : Investment, scale : Int) -> Int64? {
if self.units == "" {
None
} else {
amount_to_minor_units(self.units, scale)
}
}
///|
/// The unit price at a scale, or `None` when the document stated none.
pub fn Investment::unit_price_minor_units(
self : Investment,
scale : Int,
) -> Int64? {
if self.unit_price == "" {
None
} else {
amount_to_minor_units(self.unit_price, scale)
}
}
///|
/// The trade instant, or `None` when it is absent or not a real date.
pub fn Investment::trade_time(self : Investment) -> PostedDate? {
parse_posted_date(self.trade_date)
}
///|
/// The settlement instant, or `None` when it is absent or not a real date.
pub fn Investment::settle_time(self : Investment) -> PostedDate? {
parse_posted_date(self.settle_date)
}
///|
/// The security the record moved, as `TYPE:ID` when both are known, or just the
/// identifier when the type is missing.
pub fn Investment::security_label(self : Investment) -> String {
if self.security_id == "" {
""
} else if self.security_id_type == "" {
self.security_id
} else {
self.security_id_type + ":" + self.security_id
}
}
///|
/// Whether the record moved money the way a ledger books as a debit.
///
/// The document's own total is the authority where it is present: a buy with a
/// positive total is a refund of a purchase, and this library will not turn it
/// into a debit. Only when there is no total does the kind decide.
pub fn Investment::is_debit(self : Investment) -> Bool {
if self.total != "" && is_valid_amount(self.total) {
for c in self.total.iter() {
if c == '-' {
return true
}
}
return false
}
match self.kind {
BuyStock | Expense | MarginInterest => true
_ => false
}
}
///|
/// The money the record actually moved, after the costs it states.
///
/// A brokerage total is usually the gross amount, so a buy of 1501.00 with a
/// commission of 1.00 is 1502.00 out of the account. `None` means the document
/// stated no total, or one of the amounts will not convert at `scale`, or the
/// subtraction overflows - a caller reconciling a cash balance must not be
/// handed a number this library had to guess.
pub fn Investment::proceeds_minor_units(
self : Investment,
scale : Int,
) -> Int64? {
let total = match self.total_minor_units(scale) {
Some(value) => value
None => return None
}
let mut result = total
for field in [self.commission, self.fees, self.taxes] {
if field != "" {
let cost = match amount_to_minor_units(field, scale) {
Some(value) => value
None => return None
}
// A cost is stated as a positive number even when it reduces a sale, so
// it is always subtracted from the total.
result = match checked_sub(result, cost) {
Some(value) => value
None => return None
}
}
}
Some(result)
}
///|
/// The record as one line, for a report or a log.
pub fn Investment::to_string(self : Investment) -> String {
let parts : Array[String] = []
parts.push(self.kind.to_string())
parts.push(self.fitid)
if self.trade_date != "" {
parts.push("on " + self.trade_date)
}
if self.security_id != "" {
parts.push("security " + self.security_label())
}
if self.units != "" {
parts.push("units " + self.units)
}
if self.unit_price != "" {
parts.push("at " + self.unit_price)
}
if self.total != "" {
parts.push("total " + self.total)
}
if self.commission != "" {
parts.push("commission " + self.commission)
}
parts.join(" ")
}
///|
/// The value a field was given, or the empty string when the record never stated
/// it.
fn record_field(fields : Map[String, String], key : String) -> String {
match fields.get(key) {
Some(value) => value
None => ""
}
}
///|
/// Whether the record already has a value for a field.
///
/// The first value wins, the same rule the statement-level fields follow, so a
/// document that repeats `TOTAL` does not silently use the later one.
fn record_has(fields : Map[String, String], key : String) -> Bool {
fields.get(key) is Some(_)
}
///|
/// Turn a collected record into a transaction, or report why it cannot be one.
///
/// A record without an id cannot be de-duplicated and a record without a valid
/// trade date cannot be placed in time, so both are reported and the record is
/// dropped, the same rule the bank-transaction path applies. A total that is not
/// a plain decimal amount is reported for the same reason.
fn finish_investment(
tag : String,
line : Int,
fields : Map[String, String],
trade_tag : String,
trade_line : Int,
total_line : Int,
diagnostics : Array[Diagnostic],
) -> Investment? {
let fitid = record_field(fields, "FITID")
if fitid == "" {
diagnostics.push(error_at("missing-fitid", at_line(tag, line)))
return None
}
let trade_date = record_field(fields, "DATE")
if trade_date == "" {
diagnostics.push(error_at("invalid-posted", at_line("DTTRADE", line)))
return None
}
if !valid_date(trade_date) {
diagnostics.push(error_at("invalid-posted", at_line(trade_tag, trade_line)))
return None
}
let total = record_field(fields, "TOTAL")
if total != "" && !is_valid_amount(total) {
diagnostics.push(error_at("invalid-amount", at_line("TOTAL", total_line)))
return None
}
Some({
kind: investment_kind_of(tag),
tag,
fitid,
trade_date,
settle_date: record_field(fields, "SETTLE"),
security_id: record_field(fields, "SECID"),
security_id_type: record_field(fields, "SECIDTYPE"),
units: record_field(fields, "UNITS"),
unit_price: record_field(fields, "UNITPRICE"),
commission: record_field(fields, "COMMISSION"),
fees: record_field(fields, "FEES"),
taxes: record_field(fields, "TAXES"),
total,
sub_account_sec: record_field(fields, "SUBACCTSEC"),
sub_account_fund: record_field(fields, "SUBACCTFUND"),
income_type: record_field(fields, "INCOMETYPE"),
buy_type: record_field(fields, "BUYTYPE"),
sell_type: record_field(fields, "SELLTYPE"),
currency: record_field(fields, "CURRENCY"),
name: record_field(fields, "NAME"),
memo: record_field(fields, "MEMO"),
})
}
///|
/// Read the investment transactions out of a token stream.
///
/// This runs as its own pass because investment records have their own nesting
/// and their own field names; the bank-transaction pass is left alone, which
/// also means the `STMTTRN` inside an `INVBANKTRAN` is still reported as an
/// ordinary cash transaction by [`parse_statement`].
///
/// A record ends at its own closing tag or, in an SGML document that has none,
/// at the next record that starts. Leaf fields are attributed to whichever
/// record is open, because `FITID` inside `INVTRAN` and `FITID` inside a nested
/// `STMTTRN` mean the same thing here.
fn parse_investments(
tokens : Array[TagToken],
diagnostics : Array[Diagnostic],
) -> Array[Investment] {
let investments : Array[Investment] = []
let scopes : Array[(String, Int)] = []
let mut fields : Map[String, String] = Map([])
let mut tag = ""
let mut line = 0
let mut trade_tag = ""
let mut trade_line = 0
let mut total_line = 0
let mut open = false
for token in tokens {
let name = token.name
let token_line = token.line
let value = token.value
match close_target(name) {
Some(target) => {
while scopes.length() > 0 {
let (top, _) = scopes[scopes.length() - 1]
let _ = scopes.pop()
if top == target {
break
}
}
if open && target == tag {
match
finish_investment(
tag, line, fields, trade_tag, trade_line, total_line, diagnostics,
) {
Some(investment) => investments.push(investment)
None => ()
}
fields = Map([])
open = false
tag = ""
}
}
None =>
if is_investment_record(name) {
if open {
match
finish_investment(
tag, line, fields, trade_tag, trade_line, total_line, diagnostics,
) {
Some(investment) => investments.push(investment)
None => ()
}
fields = Map([])
}
tag = name
line = token_line
trade_tag = ""
trade_line = token_line
total_line = token_line
open = true
scopes.push((name, token_line))
} else if is_investment_aggregate(name) {
scopes.push((name, token_line))
} else if open {
match name {
"FITID" =>
if !record_has(fields, "FITID") {
fields["FITID"] = value
}
"DTTRADE" | "DTPOSTED" =>
if !record_has(fields, "DATE") {
fields["DATE"] = value
trade_tag = name
trade_line = token_line
}
"DTSETTLE" => fields["SETTLE"] = value
"UNIQUEID" => fields["SECID"] = value
"UNIQUEIDTYPE" => fields["SECIDTYPE"] = value
"UNITS" => fields["UNITS"] = value
"UNITPRICE" => fields["UNITPRICE"] = value
"COMMISSION" => fields["COMMISSION"] = value
"FEES" => fields["FEES"] = value
"TAXES" => fields["TAXES"] = value
"TOTAL" | "TRNAMT" =>
if !record_has(fields, "TOTAL") {
fields["TOTAL"] = value
total_line = token_line
}
"SUBACCTSEC" => fields["SUBACCTSEC"] = value
"SUBACCTFUND" => fields["SUBACCTFUND"] = value
"INCOMETYPE" => fields["INCOMETYPE"] = value
"BUYTYPE" => fields["BUYTYPE"] = value
"SELLTYPE" => fields["SELLTYPE"] = value
"CURRENCY" => fields["CURRENCY"] = value
"NAME" => if !record_has(fields, "NAME") { fields["NAME"] = value }
"MEMO" => if !record_has(fields, "MEMO") { fields["MEMO"] = value }
_ => ()
}
}
}
}
if open {
match
finish_investment(
tag, line, fields, trade_tag, trade_line, total_line, diagnostics,
) {
Some(investment) => investments.push(investment)
None => ()
}
}
investments
}