UNPKG

sprolosta

Version:

A nodejs app that computes the profit / loss of stock trading for each financial year

550 lines (481 loc) 20.9 kB
/** * Copyright (c) 2023 TYO Lab (TYONLINE TECHNOLOGY PTY. LTD.). All rights reserved. * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ const models = require("../lib/models"); const utils = require("../lib/utils"); const fs = require("fs"); function Broker () { this.name = "general"; this.shortsell_allowed = false; this.missing_trades = new Map(); this.minimum_commas_count = 5; // 6 columns at least this.quote_count_needed = false; } Broker.prototype.load = function (files, offset) { throw new Error("Not implemented"); } Broker.prototype.calculate_financial_year_profit = function (portfolio, year, options) { options = options || {}; let financial_year = new models.FinancialYear(); financial_year.year = year; let total_profit = 0; let total_profit_gain = 0; let total_profit_loss = 0; let total_profit_trades = 0; // round trip count that makes profit let total_loss_trades = 0; // round trip count that makes loss let total_discount = 0; let total_trades = 0; let total_cost = 0; let total_buy = 0; let total_sell = 0; let trade_profit_max = new models.Trade(); let trade_loss_max = new models.Trade(); portfolio.holdings.forEach(function (holding) { let profits_year = holding.profits.get(year); // the list of profits for the year if (profits_year) { let sub_discount = 0; let sub_profit = 0; let sub_profit_gain = 0; let sub_profit_loss = 0; let sub_profit_trades = 0; let sub_loss_trades = 0; let sub_trades = 0; let sub_cost = 0; profits_year.forEach(function (profit) { if (profit.discount_eligible) sub_discount += profit.profit; else sub_profit += profit.profit; // sub_trades += profit.transactions.length; if (profit.profit > 0) { sub_profit_gain += profit.profit; sub_profit_trades++; if (profit.profit > trade_profit_max.profit) { trade_profit_max.symbol = holding.symbol; trade_profit_max.quantity = profit.quantity; trade_profit_max.cost_price = profit.cost_price; trade_profit_max.close_price = profit.close_price; trade_profit_max.profit = profit.profit; trade_profit_max.type = profit.trade_type; } } else { sub_profit_loss += profit.profit; sub_loss_trades++; if (profit.profit < trade_loss_max.profit) { trade_loss_max.symbol = holding.symbol; trade_loss_max.quantity = profit.quantity; trade_loss_max.cost_price = profit.cost_price; trade_loss_max.close_price = profit.close_price; trade_loss_max.profit = profit.profit; trade_loss_max.type = profit.trade_type; } } sub_cost += profit.cost; }); total_profit += sub_profit; total_profit_gain += sub_profit_gain; total_profit_loss += sub_profit_loss; total_profit_trades += sub_profit_trades; total_loss_trades += sub_loss_trades; total_discount += sub_discount; total_trades += sub_trades; total_cost += sub_cost; if (options.details) { console.log("Year" + year +" details for " + holding.symbol + ":"); console.log("Profit: " + sub_profit); console.log("Profit gain: " + sub_profit_gain); console.log("Profit loss: " + sub_profit_loss); console.log("Profit trades: " + sub_profit_trades); console.log("Loss trades: " + sub_loss_trades); console.log("Discount: " + sub_discount); console.log("Trades: " + sub_trades); console.log("Cost: " + sub_cost); } } let trade_value = holding.trade_values.get(year); if (trade_value) { total_buy += trade_value.buy; total_sell += trade_value.sell; total_trades += trade_value.transactions.length; } }); financial_year.profit = total_profit; financial_year.profit_discount = total_discount; financial_year.total_trades = total_trades; financial_year.total_cost = total_cost; financial_year.total_buy = total_buy; financial_year.total_sell = total_sell; financial_year.total_profit_gain = total_profit_gain; financial_year.total_profit_loss = total_profit_loss; financial_year.total_profit_trades = total_profit_trades; financial_year.total_loss_trades = total_loss_trades; financial_year.trade_profit_max = trade_profit_max; financial_year.trade_loss_max = trade_loss_max; return financial_year; } Broker.prototype.get_holding_profits_year = function (holding, financial_year) { // now decide which financial year the transaction belongs to // for any given year, the financial year is from 1 July of previous year to 30 June of current year // e.g. for 2018, the financial year is from 1 July 2018 to 30 June 2019 let profits_year = holding.profits.get(financial_year); if (profits_year == null) { profits_year = []; holding.profits.set(financial_year, profits_year); } return profits_year; } Broker.prototype.calculate_profit = function (holding, transaction, financial_year) { let profits_year = this.get_holding_profits_year(holding, financial_year); let transactions = holding.records; // transactions.pop(); // remove the last transaction, which is the current transaction let last_transaction = transactions.pop(); // now we need to decide whether there is discount for the capital gain let acquired_quantity_abs = 0; let acquired_quantity = 0; let quantity_target = Math.abs(transaction.quantity); let quantity_balance = quantity_target; // let quantity_surplus = 0; let profit_all = 0; let cost_all = 0; // let fee_absorbed = false; while(last_transaction) { if (last_transaction.type == transaction.type) { console.error("The last transaction is the same type as the current transaction"); console.error("Last transaction: " + JSON.stringify(last_transaction)); console.error("Current transaction: " + JSON.stringify(transaction)); process.exit(1); } acquired_quantity += (last_transaction.quantity); let quantity_last = Math.abs(last_transaction.quantity); let profit_quantity = 0; let cost = 0; if ((acquired_quantity_abs = Math.abs(acquired_quantity)) <= quantity_target) { profit_quantity = quantity_last; quantity_balance = quantity_target - acquired_quantity_abs; // there are a couple of ways to calculate the cost // using the average price // using the last transaction total // cost = last_transaction.total; } else { profit_quantity = quantity_balance; quantity_balance = 0; } // fees are absorbed in the average price cost = /* (quantity_last <= profit_quantity ? last_transaction.value : */ ((holding.average_price * profit_quantity))/* + last_transaction.fee */; cost_all += cost; let profit = new models.Profit(); /** * maybe there are a few ways to calculate the average price * 1. change the average price * 2. keep the average price */ // let left_cost = holding.cost - transaction.total; // holding.average_price = left_cost / holding.quantity; // let cost = holding.quantity == quantity_target ? holding.cost : holding.average_price * transaction.quantity; let total = transaction.price * profit_quantity; let profit_num = last_transaction.quantity > 0 ? (total - cost) : (cost - total); // if (fee_absorbed == false) { // // if there are multiple transactions, then only the last transaction will absorb the fee // profit_num -= transaction.fee; // fee_absorbed = true; // } profit_all += profit_num; profit.quantity = profit_quantity; profit.cost = cost; profit.cost_price = holding.average_price; profit.close_price = transaction.price; profit.profit_price = profit.close_price - profit.cost_price; // profit per share profit.profit = profit_num; profit.transaction_open = last_transaction; profit.transaction_close = transaction; profit.trade_type = last_transaction.type; // profit.transactions.unshift(last_transaction); profits_year.push(profit); // now check if the asset hold more than 12 months // if so, then there is discount for the capital gain // worry about the discount only if there is profit if (profit_num > 0) { let year = last_transaction.date.getFullYear(); let close_year = transaction.date.getFullYear(); if (year != close_year) { let month = last_transaction.date.getMonth(); let close_month = transaction.date.getMonth(); if (month <= close_month) { let day = last_transaction.date.getDate(); let close_day = transaction.date.getDate(); if (day < close_day) { // the asset is hold more than 12 months profit.discount_eligible = true; // profit.discount_quantity += last_transaction.quantity; } } } } if (acquired_quantity_abs >= quantity_target) { profit.year_init = last_transaction.date; profit.year_close = transaction.date; let year1 = utils.get_financial_year(last_transaction.date); let year2 = utils.get_financial_year(transaction.date); if (year1 != year2) { console.debug("Close trade that spans over 2 financial years"); console.debug("Symbol: " + transaction.symbol); console.debug("Trade 1: " + last_transaction.date.toISOString() + " " + last_transaction.type + " " + last_transaction.quantity); console.debug("Trade 2: " + transaction.date.toISOString() + " " + transaction.type + " " + quantity_target); } // if (acquired_quantity_abs > quantity_target) { // // the last transaction is not fully used // // so we need to put it back to the stack // let adjusted_transaction = last_transaction.copy(); // adjusted_transaction.quantity = acquired_quantity - quantity_target; // transactions.push(adjusted_transaction); // } break; } last_transaction = transactions.pop(); } if (acquired_quantity_abs < quantity_target) { // we didn't acquire enough quantity so this becomes opening a new position in the opposition direction // there is surplus, need to put it back to the transactions / holding let quantity_surplus = transaction.quantity + acquired_quantity; // holding.quantity += quantity_surplus; // holding.cost += transaction.total - ; // holding.average_price = transaction.price; // holding.cost / holding.quantity; transaction.quantity = quantity_surplus; transaction.value = transaction.price * quantity_surplus; transaction.total = transaction.value + transaction.fee; transactions.push(transaction); } else if (acquired_quantity_abs > quantity_target) { // position partially closed if (!last_transaction) { console.error("No last transaction"); process.exit(1); } // keep the average price // holding.average_price = last_transaction.price; last_transaction.quantity = acquired_quantity + transaction.quantity; // last_transaction.fee = 0; // it is already included in the profit last_transaction.value = last_transaction.price * last_transaction.quantity; last_transaction.total = holding.average_price * last_transaction.quantity; transactions.push(last_transaction); } else { // position fully closed } holding.average_close = holding.average_close == 0 ? transaction.price : (holding.average_close + transaction.price) / 2; holding.profit += (profit_all /* - transaction.fee */); return profit_all; } Broker.prototype.update_holding = function (portfolio, symbol, trades) { if (!trades || trades.length == 0) { console.log("No trades to update"); return; } // let transactions = []; // Update portfolio let holding = null; // let symbol = trades[0].symbol; if (portfolio.holdings.has(symbol)) { holding = portfolio.holdings.get(symbol); } for (let i = 0; i < trades.length; i++) { let transaction = trades[i]; if (!holding) { // new holding holding = new models.Holding(); holding.symbol = symbol; holding.company = transaction.company; portfolio.holdings.set(symbol, holding); } let financial_year = utils.get_financial_year(transaction.date); let trade_value = holding.trade_values.get(financial_year); if (!trade_value) { trade_value = new models.TradeValue(); trade_value.year = financial_year; holding.trade_values.set(financial_year, trade_value); } trade_value.transactions.push(transaction); if (transaction.type == "buy") { trade_value.buy += transaction.total; } else if (transaction.type == "sell") { trade_value.sell += transaction.total; } // else if (transaction.type == "dividends") { // } // else if (transaction.type == "deposit") { // } // else if (transaction.type == "withdraw") { // } else { console.error("Unknown transaction type: " + transaction.type); process.exit(1); } let quantity = transaction.quantity; let transaction_value = transaction.value; let transaction_cost = transaction.total; if (quantity != 0) { if ((holding.quantity > 0 && quantity < 0) || (holding.quantity < 0 && quantity > 0)) { // close the position fully or partially let profit = this.calculate_profit(holding, transaction, financial_year); // console.debug("Profit: " + profit); } else { // just update the position if (holding.quantity == 0) { holding.average_price = transaction_cost / quantity; // holding.cost / holding.quantity; } else { // holding cost will be cumulated with the loss / profit, so we can't use it here // when the loss is realised and if the stock gets re-bought, the average price should reflect it // holding.average_price = (holding.cost + transaction_cost) / (holding.quantity + quantity); holding.average_price = (holding.average_price * holding.quantity + transaction_cost) / (holding.quantity + quantity); // console.debug("Average price now: " + holding.average_price) } if (holding.average_price < 0) { console.error("Average price is negative: " + holding.average_price); process.exit(1); } holding.records.push(transaction); } holding.quantity += quantity; if (holding.quantity == 0) { // close the position // profit loss recorded, cost and value reset holding.cost = 0; holding.value = 0; } else { holding.cost += transaction_cost; // transaction.total; holding.value += transaction_value; // transaction.value; } } } } Broker.prototype.quote_count_check = function (line) { if (!this.quote_count_needed) return true; var quote_count = (line.match(/"/g) || []).length; /** * as we need * symbol, date, type, quantity, price and total at least * so we need at least 6 commas */ return quote_count > (this.minimum_commas_count * 2) && (quote_count % 2) == 0; } /** * When this happens the line is normally the header line * * @param {*} line * @returns */ Broker.prototype.is_data_line_started = function (line) { var count = (line.match(/,/g) || []).length; if (count < this.minimum_commas_count) { return false; } return this.quote_count_check(line); } Broker.prototype.is_data_line_ended = function (line) { return line.trim().length == 0; } Broker.prototype.line_to_transaction = function (fields) { throw new Error("Func (line_to_transaction) is Not implemented"); } Broker.prototype.load_content_common = function (trades, content, options) { let { index, offset } = options; let count = 0; let lines = content.split('\n'); for (let s = 0; s < offset; s++) { lines.shift(); } let start = false; for (let j = 0; j < lines.length; j++) { let line = lines[j]; if (!start) { // for CommSec, the first data line starts with "Code," // if (line.startsWith("Code,")) if (this.is_data_line_started(line)) start = true; continue; } if (this.is_data_line_ended(line)) { break; } let fields = line.split(','); fields = fields.map(function (field) { try { return JSON.parse(field.trim()); } catch (e) { // console.log("Error parsing field: " + field); return field.trim(); } }); let transaction = this.line_to_transaction(fields, (++count) + index); if (!transaction) { // not all CVS lines are transactions // e.g. the transaction records downloaded from the CommSec website continue; } if (trades.first == null || trades.first > transaction.date) { trades.first = transaction.date; } if (trades.last == null || trades.last < transaction.date) { trades.last = transaction.date; } let year = transaction.date.getFullYear(); trades.years.add(year); let transactions = null; if (trades.symbols.has(transaction.symbol)) { transactions = trades.symbols.get(transaction.symbol); } else { transactions = []; trades.symbols.set(transaction.symbol, transactions); } transactions.push(transaction); } return count; } Broker.prototype.load_content = function (trades, content, options) { return this.load_content_common(trades, content, options); } /** * Load the broker's data from the CSV file. */ Broker.prototype.load = function (files, offset, options) { console.log("Loading CommSec data..."); if (!options && typeof offset == 'object') { options = offset; offset = 0; } offset = offset || 0; options = options || {}; let trades = new models.Trades(); if (!Array.isArray(files)) { files = [files]; } let count = 0; for (let i = 0; i < files.length; i++) { try { if (!fs.existsSync(files[i])) { console.error("File not found: " + files[i]); process.exit(1); } console.log("Loading transactions file: " + files[i]) let content = fs.readFileSync(files[i], 'utf8'); count += this.load_content(trades, content, { index: count, offset: offset }); } catch (err) { console.log(err); } } return trades; } module.exports = Broker;