2021 06 07 dlc wallet pnl (#3229)

* WIP

* Add profit and loss and rate of return for entire wallet

* Fix rebase

* Address part 1 of code review from Ben

* Add unit test for wallet accounting
This commit is contained in:
Chris Stewart 2021-06-08 15:05:40 -05:00 committed by GitHub
parent 7ba7f8b9ba
commit 9431be2f25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 249 additions and 57 deletions

View File

@ -0,0 +1,10 @@
package org.bitcoins.commons.serializers
object PicklerKeys {
final val myCollateral: String = "myCollateral"
final val theirCollateral: String = "theirCollateral"
final val myPayout: String = "myPayout"
final val theirPayout: String = "theirPayout"
final val pnl: String = "pnl"
final val rateOfReturn: String = "rateOfReturn"
}

View File

@ -9,6 +9,7 @@ import org.bitcoins.core.crypto.{
MnemonicCode
}
import org.bitcoins.core.currency.{Bitcoins, Satoshis}
import org.bitcoins.core.dlc.accounting.DLCWalletAccounting
import org.bitcoins.core.hd.AddressType
import org.bitcoins.core.number.UInt32
import org.bitcoins.core.protocol.dlc.models.DLCStatus._
@ -260,10 +261,7 @@ object Picklers {
)
}
private val myPayoutKey: String = "myPayout"
private val counterPartyPayoutKey: String = "counterPartyPayout"
private val pnlKey: String = "pnl"
private val rateOfReturnKey: String = "rateOfReturn"
implicit val claimedW: Writer[Claimed] = writer[Obj].comap { claimed =>
import claimed._
@ -296,11 +294,11 @@ object Picklers {
"oracleSigs" -> oracleSigs.map(sig => Str(sig.hex)),
"outcomes" -> outcomesJs,
"oracles" -> oraclesJs,
myPayoutKey -> Num(claimed.myPayout.satoshis.toLong.toDouble),
PicklerKeys.myPayout -> Num(claimed.myPayout.satoshis.toLong.toDouble),
counterPartyPayoutKey -> Num(
claimed.counterPartyPayout.satoshis.toLong.toDouble),
pnlKey -> Num(claimed.pnl.satoshis.toLong.toDouble),
rateOfReturnKey -> Num(claimed.rateOfReturn.toDouble)
PicklerKeys.pnl -> Num(claimed.pnl.satoshis.toLong.toDouble),
PicklerKeys.rateOfReturn -> Num(claimed.rateOfReturn.toDouble)
)
}
@ -336,11 +334,12 @@ object Picklers {
"oracleSigs" -> oracleSigs.map(sig => Str(sig.hex)),
"outcomes" -> outcomesJs,
"oracles" -> oraclesJs,
myPayoutKey -> Num(remoteClaimed.myPayout.satoshis.toLong.toDouble),
PicklerKeys.myPayout -> Num(
remoteClaimed.myPayout.satoshis.toLong.toDouble),
counterPartyPayoutKey -> Num(
remoteClaimed.counterPartyPayout.satoshis.toLong.toDouble),
pnlKey -> Num(remoteClaimed.pnl.satoshis.toLong.toDouble),
rateOfReturnKey -> Num(remoteClaimed.rateOfReturn.toDouble)
PicklerKeys.pnl -> Num(remoteClaimed.pnl.satoshis.toLong.toDouble),
PicklerKeys.rateOfReturn -> Num(remoteClaimed.rateOfReturn.toDouble)
)
}
@ -363,11 +362,11 @@ object Picklers {
"remoteCollateral" -> Num(remoteCollateral.satoshis.toLong.toDouble),
"fundingTxId" -> Str(fundingTxId.hex),
"closingTxId" -> Str(closingTxId.hex),
myPayoutKey -> Num(refunded.myPayout.satoshis.toLong.toDouble),
PicklerKeys.myPayout -> Num(refunded.myPayout.satoshis.toLong.toDouble),
counterPartyPayoutKey -> Num(
refunded.counterPartyPayout.satoshis.toLong.toDouble),
pnlKey -> Num(refunded.pnl.satoshis.toLong.toDouble),
rateOfReturnKey -> Num(refunded.rateOfReturn.toDouble)
PicklerKeys.pnl -> Num(refunded.pnl.satoshis.toLong.toDouble),
PicklerKeys.rateOfReturn -> Num(refunded.rateOfReturn.toDouble)
)
}
@ -441,7 +440,7 @@ object Picklers {
throw new IllegalArgumentException(s"Unexpected outcome $signed")
}
lazy val myPayoutJs = obj(myPayoutKey)
lazy val myPayoutJs = obj(PicklerKeys.myPayout)
lazy val myPayoutOpt = myPayoutJs.numOpt.map(sats => Satoshis(sats.toLong))
lazy val theirPayoutJs = obj(counterPartyPayoutKey)
lazy val theirPayoutOpt =
@ -566,6 +565,23 @@ object Picklers {
}
}
implicit val dlcWalletAccountingWriter: Writer[DLCWalletAccounting] = {
writer[Obj].comap { walletAccounting: DLCWalletAccounting =>
Obj(
PicklerKeys.myCollateral -> Num(
walletAccounting.myCollateral.satoshis.toLong.toDouble),
PicklerKeys.theirCollateral -> Num(
walletAccounting.theirCollateral.satoshis.toLong.toDouble),
PicklerKeys.myPayout -> Num(
walletAccounting.myPayout.satoshis.toLong.toDouble),
PicklerKeys.theirPayout -> Num(
walletAccounting.theirPayout.satoshis.toLong.toDouble),
PicklerKeys.pnl -> Num(walletAccounting.pnl.satoshis.toLong.toDouble),
PicklerKeys.rateOfReturn -> Num(walletAccounting.rateOfReturn.toDouble)
)
}
}
implicit val mnemonicCodePickler: ReadWriter[MnemonicCode] =
readwriter[String].bimap(
_.words.mkString(" "),

View File

@ -1729,6 +1729,8 @@ object ConsoleCli {
case ZipDataDir(path) =>
RequestParam("zipdatadir", Seq(up.writeJs(path)))
case GetDLCWalletAccounting =>
RequestParam("getdlcwalletaccounting")
case GetVersion =>
// skip sending to server and just return version number of cli
return Success(EnvUtil.getVersion)
@ -1995,6 +1997,7 @@ object CliCommand {
case class GetUnconfirmedBalance(isSats: Boolean) extends AppServerCliCommand
case class GetBalances(isSats: Boolean) extends AppServerCliCommand
case class GetAddressInfo(address: BitcoinAddress) extends AppServerCliCommand
case object GetDLCWalletAccounting extends AppServerCliCommand
case class GetTransaction(txId: DoubleSha256DigestBE)
extends AppServerCliCommand

View File

@ -13,6 +13,9 @@ object GlobalData {
val currentReservedBalance: StringProperty = StringProperty("0")
val currentTotalBalance: StringProperty = StringProperty("0")
val currentPNL: StringProperty = StringProperty("0")
val rateOfReturn: StringProperty = StringProperty("0%")
val syncHeight: StringProperty = StringProperty("Syncing headers...")
var network: BitcoinNetwork = _

View File

@ -30,28 +30,35 @@ abstract class WalletGUI {
dlcPane.model.setUp()
}
private val satsProperty = StringProperty(" sats")
private lazy val confirmedText = new Label() {
text <== StringProperty(
"Confirmed balance:\t\t") + GlobalData.currentConfirmedBalance + StringProperty(
" sats")
"Confirmed balance:\t\t") + GlobalData.currentConfirmedBalance + satsProperty
}
private lazy val unconfirmedText = new Label() {
text <== StringProperty(
"Unconfirmed balance:\t") + GlobalData.currentUnconfirmedBalance + StringProperty(
" sats")
"Unconfirmed balance:\t") + GlobalData.currentUnconfirmedBalance + satsProperty
}
private lazy val reservedText = new Label() {
text <== StringProperty(
"Reserved balance:\t\t") + GlobalData.currentReservedBalance + StringProperty(
" sats")
"Reserved balance:\t\t") + GlobalData.currentReservedBalance + satsProperty
}
private lazy val totalBalanceText = new Label() {
text <== StringProperty(
"Total balance:\t\t\t") + GlobalData.currentTotalBalance + StringProperty(
" sats")
"Total balance:\t\t\t") + GlobalData.currentTotalBalance + satsProperty
}
private lazy val pnlText = new Label() {
text <== StringProperty(
"Profit and Loss:\t\t\t") + GlobalData.currentPNL + satsProperty
}
private lazy val rateOfReturnText = new Label() {
text <== StringProperty("Rate of Return:\t\t\t") + GlobalData.rateOfReturn
}
private[gui] lazy val dlcPane = new DLCPane(glassPane)
@ -66,6 +73,11 @@ abstract class WalletGUI {
totalBalanceText)
}
private lazy val walletAccountingBox = new VBox {
spacing = 10
children = Vector(pnlText, rateOfReturnText)
}
private lazy val getNewAddressButton = new Button {
text = "Get New Address"
onAction = _ => model.onGetNewAddress()
@ -84,7 +96,8 @@ abstract class WalletGUI {
sendButton.prefWidth <== width
getNewAddressButton.maxWidth = 300
sendButton.maxWidth = 300
children = Vector(balanceBox, getNewAddressButton, sendButton)
children =
Vector(balanceBox, walletAccountingBox, getNewAddressButton, sendButton)
}
lazy val bottomStack: StackPane = new StackPane() {

View File

@ -1,9 +1,12 @@
package org.bitcoins.gui
import akka.actor.{ActorSystem, Cancellable}
import grizzled.slf4j.Logging
import org.bitcoins.cli.CliCommand._
import org.bitcoins.cli.ConsoleCli
import org.bitcoins.commons.serializers.PicklerKeys
import org.bitcoins.core.currency.{Bitcoins, Satoshis}
import org.bitcoins.core.dlc.accounting.RateOfReturnUtil
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.core.wallet.fee.FeeUnit
import org.bitcoins.gui.dialog._
@ -19,7 +22,8 @@ import scala.concurrent.duration.DurationInt
import scala.concurrent.{Await, Promise}
import scala.util.{Failure, Success, Try}
class WalletGUIModel(dlcModel: DLCPaneModel)(implicit system: ActorSystem) {
class WalletGUIModel(dlcModel: DLCPaneModel)(implicit system: ActorSystem)
extends Logging {
var taskRunner: TaskRunner = _
import system.dispatcher
@ -33,6 +37,7 @@ class WalletGUIModel(dlcModel: DLCPaneModel)(implicit system: ActorSystem) {
override def run(): Unit = {
Platform.runLater {
updateBalance()
updateWalletAccounting()
updateWalletInfo()
dlcModel.updateDLCs()
}
@ -151,4 +156,19 @@ class WalletGUIModel(dlcModel: DLCPaneModel)(implicit system: ActorSystem) {
}.showAndWait()
}
}
private def updateWalletAccounting(): Unit = {
ConsoleCli.exec(GetDLCWalletAccounting, GlobalData.consoleCliConfig) match {
case Failure(err) =>
logger.error(s"Error fetching accounting", err)
case Success(commandReturn) =>
val json = ujson.read(commandReturn).obj
val pnl = json(PicklerKeys.pnl).num.toLong.toString
val rateOfReturn = json(PicklerKeys.rateOfReturn).num
val rorPrettyPrint = RateOfReturnUtil.prettyPrint(rateOfReturn)
GlobalData.currentPNL.value = pnl
GlobalData.rateOfReturn.value = rorPrettyPrint
()
}
}
}

View File

@ -11,6 +11,7 @@ import org.bitcoins.core.api.wallet.{AddressInfo, CoinSelectionAlgo}
import org.bitcoins.core.config.RegTest
import org.bitcoins.core.crypto.ExtPublicKey
import org.bitcoins.core.currency.{Bitcoins, CurrencyUnit, Satoshis}
import org.bitcoins.core.dlc.accounting.DLCWalletAccounting
import org.bitcoins.core.hd._
import org.bitcoins.core.number.{UInt32, UInt64}
import org.bitcoins.core.protocol.BlockStamp.{
@ -1705,5 +1706,27 @@ class RoutesSpec extends AnyWordSpec with ScalatestRouteTest with MockFactory {
}
}
"get wallet accounting" in {
val accounting = DLCWalletAccounting(myCollateral = Satoshis.one,
theirCollateral = Satoshis.one,
myPayout = Satoshis(2),
theirPayout = Satoshis.zero)
(mockWalletApi.getWalletAccounting: () => Future[DLCWalletAccounting])
.expects()
.returning(Future.successful(accounting))
val route = walletRoutes.handleCommand(
ServerCommand("getdlcwalletaccounting", Arr()))
Get() ~> route ~> check {
assert(contentType == `application/json`)
val str = responseAs[String]
val expected =
s"""{"result":{"myCollateral":1,"theirCollateral":1,"myPayout":2,"theirPayout":0,"pnl":1,"rateOfReturn":1},"error":null}""".stripMargin
assert(str == expected)
}
}
}
}

View File

@ -794,6 +794,13 @@ case class WalletRoutes(wallet: AnyDLCHDWalletApi)(implicit
Server.httpSuccess(fee.toString)
}
}
case ServerCommand("getdlcwalletaccounting", _) =>
complete {
wallet.getWalletAccounting().map { accounting =>
Server.httpSuccess(writeJs(accounting))
}
}
}
/** Returns information about the state of our wallet */

View File

@ -21,7 +21,7 @@ class DLCAccountingTest extends BitcoinSUnitTest {
//we make 50,000 sats (their collateral) is the profit
assert(accounting1.pnl == theirCollateral)
assert(accounting1.rorPrettyPrint == "100%")
assert(accounting1.rorPrettyPrint == "100.00%")
}
it must "calculate basic pnl where we lose all funds" in {
@ -30,16 +30,16 @@ class DLCAccountingTest extends BitcoinSUnitTest {
val accounting1 = DLCAccounting(
dlcId = Sha256Digest.empty,
myCollateral = Satoshis(50000),
theirCollateral = Satoshis(50000),
myCollateral = myCollateral,
theirCollateral = theirCollateral,
myPayout = Satoshis.zero,
theirPayout = myCollateral + theirCollateral
)
//we lose 50,000 sats (my collateral) is the loss
assert(accounting1.pnl == Satoshis(-50000))
assert(accounting1.pnl == -myCollateral)
assert(accounting1.rateOfReturn == -1)
assert(accounting1.rorPrettyPrint == "-100%")
assert(accounting1.rorPrettyPrint == "-100.00%")
}
it must "calculate basic pnl where funds are refunded" in {
@ -48,8 +48,8 @@ class DLCAccountingTest extends BitcoinSUnitTest {
val accounting1 = DLCAccounting(
dlcId = Sha256Digest.empty,
myCollateral = Satoshis(50000),
theirCollateral = Satoshis(50000),
myCollateral = myCollateral,
theirCollateral = theirCollateral,
myPayout = myCollateral,
theirPayout = theirCollateral
)
@ -57,6 +57,6 @@ class DLCAccountingTest extends BitcoinSUnitTest {
//collateral refunded, so no pnl
assert(accounting1.pnl == Satoshis.zero)
assert(accounting1.rateOfReturn == 0)
assert(accounting1.rorPrettyPrint == "0%")
assert(accounting1.rorPrettyPrint == "0.00%")
}
}

View File

@ -0,0 +1,16 @@
package org.bitcoins.core.dlc.accounting
import org.bitcoins.testkitcore.util.BitcoinSUnitTest
class RateOfReturnUtilTest extends BitcoinSUnitTest {
behavior of "RateOfReturnUtil"
it must "pretty print strings with percentages correctly" in {
RateOfReturnUtil.prettyPrint(0) must be("0.00%")
RateOfReturnUtil.prettyPrint(-1) must be("-100.00%")
RateOfReturnUtil.prettyPrint(1) must be("100.00%")
RateOfReturnUtil.prettyPrint(1.23) must be("123.00%")
RateOfReturnUtil.prettyPrint(1.23456) must be("123.46%")
RateOfReturnUtil.prettyPrint(1.23454) must be("123.45%")
}
}

View File

@ -8,19 +8,5 @@ case class DLCAccounting(
myCollateral: CurrencyUnit,
theirCollateral: CurrencyUnit,
myPayout: CurrencyUnit,
theirPayout: CurrencyUnit) {
/** Profit and loss for the DLC
* @see https://www.investopedia.com/terms/p/plstatement.asp
*/
val pnl: CurrencyUnit = myPayout - myCollateral
/** Rate of return for the DLC
* @see https://www.investopedia.com/terms/r/rateofreturn.asp
*/
val rateOfReturn: BigDecimal = pnl.toBigDecimal / myCollateral.toBigDecimal
val rorPrettyPrint: String = {
RateOfReturnUtil.prettyPrint(rateOfReturn)
}
}
theirPayout: CurrencyUnit)
extends PayoutAccounting

View File

@ -0,0 +1,30 @@
package org.bitcoins.core.dlc.accounting
import org.bitcoins.core.currency.{CurrencyUnit, CurrencyUnits}
/** Similar to [[org.bitcoins.core.dlc.accounting.DLCAccounting]], but
* represents the entire accounting for the wallet
*/
case class DLCWalletAccounting(
myCollateral: CurrencyUnit,
theirCollateral: CurrencyUnit,
myPayout: CurrencyUnit,
theirPayout: CurrencyUnit)
extends PayoutAccounting
object DLCWalletAccounting {
def fromDLCAccounting(
accountings: Vector[DLCAccounting]): DLCWalletAccounting = {
val myCollateral =
accountings.foldLeft(CurrencyUnits.zero)(_ + _.myCollateral)
val theirCollateral =
accountings.foldLeft(CurrencyUnits.zero)(_ + _.theirCollateral)
val myPayouts = accountings.foldLeft(CurrencyUnits.zero)(_ + _.myPayout)
val theirPayouts =
accountings.foldLeft(CurrencyUnits.zero)(_ + _.theirPayout)
DLCWalletAccounting(myCollateral, theirCollateral, myPayouts, theirPayouts)
}
}

View File

@ -0,0 +1,28 @@
package org.bitcoins.core.dlc.accounting
import org.bitcoins.core.currency.CurrencyUnit
/** Utility trait for metrics we need to do accounting */
trait PayoutAccounting {
def myCollateral: CurrencyUnit
def theirCollateral: CurrencyUnit
def myPayout: CurrencyUnit
def theirPayout: CurrencyUnit
/** Profit and loss for the DLC
* @see https://www.investopedia.com/terms/p/plstatement.asp
*/
def pnl: CurrencyUnit = myPayout - myCollateral
/** Rate of return for the DLC
* @see https://www.investopedia.com/terms/r/rateofreturn.asp
*/
def rateOfReturn: BigDecimal = pnl.toBigDecimal / myCollateral.toBigDecimal
def rorPrettyPrint: String = {
RateOfReturnUtil.prettyPrint(rateOfReturn)
}
}

View File

@ -2,7 +2,9 @@ package org.bitcoins.core.dlc.accounting
object RateOfReturnUtil {
/** @see https://alvinalexander.com/scala/how-to-format-numbers-commas-international-currency-in-scala/ */
def prettyPrint(ror: BigDecimal): String = {
(ror * 100).toString() + "%"
val percent = ror * 100
f"${percent}%1.2f" + "%"
}
}

View File

@ -44,7 +44,7 @@ sealed trait ClosedDLCStatus extends BroadcastedDLCStatus {
def myPayout: CurrencyUnit
def counterPartyPayout: CurrencyUnit
private def accounting: DLCAccounting = {
def accounting: DLCAccounting = {
DLCAccounting(dlcId,
localCollateral,
remoteCollateral,

View File

@ -7,6 +7,7 @@ import org.bitcoins.core.api.wallet.db._
import org.bitcoins.core.config.BitcoinNetwork
import org.bitcoins.core.crypto.ExtPublicKey
import org.bitcoins.core.currency._
import org.bitcoins.core.dlc.accounting.DLCWalletAccounting
import org.bitcoins.core.hd._
import org.bitcoins.core.number._
import org.bitcoins.core.protocol._
@ -1213,6 +1214,18 @@ abstract class DLCWallet
} yield refundTx
}
override def getWalletAccounting(): Future[DLCWalletAccounting] = {
val dlcsF = listDLCs()
for {
dlcs <- dlcsF
closed = dlcs.collect { case c: ClosedDLCStatus =>
c
} //only get closed dlcs for accounting
accountings = closed.map(_.accounting)
walletAccounting = DLCWalletAccounting.fromDLCAccounting(accountings)
} yield walletAccounting
}
override def listDLCs(): Future[Vector[DLCStatus]] = {
for {
ids <- dlcDAO.findAll().map(_.map(_.dlcId))

View File

@ -2,6 +2,7 @@ package org.bitcoins.dlc.wallet
import org.bitcoins.core.api.wallet._
import org.bitcoins.core.currency.Satoshis
import org.bitcoins.core.dlc.accounting.DLCWalletAccounting
import org.bitcoins.core.number.UInt32
import org.bitcoins.core.protocol.dlc.models.DLCMessage.{
DLCAccept,
@ -99,6 +100,9 @@ trait DLCWalletApi { self: WalletApi =>
def findDLC(dlcId: Sha256Digest): Future[Option[DLCStatus]]
def cancelDLC(dlcId: Sha256Digest): Future[Unit]
/** Retrieves accounting and financial metrics for the entire dlc wallet */
def getWalletAccounting(): Future[DLCWalletAccounting]
}
/** An HDWallet that supports DLCs and both Neutrino and SPV methods of syncing */

View File

@ -1,17 +1,15 @@
package org.bitcoins.dlc.wallet.accounting
import org.bitcoins.core.dlc.accounting.DLCAccounting
import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.dlc.wallet.models.{DLCAcceptDb, DLCDb, DLCOfferDb}
object AccountingUtil {
/** Calculates the profit and loss for the given dlc */
def calculatePnl(
dlcDb: DLCDb,
offerDb: DLCOfferDb,
acceptDb: DLCAcceptDb,
closingTx: Transaction): DLCAccounting = {
def calculatePnl(financials: DLCAccountingDbs): DLCAccounting = {
val dlcDb = financials.dlcDb
val offerDb = financials.offerDb
val acceptDb = financials.acceptDb
val closingTx = financials.closingTx
val (myCollateral, theirCollateral, myPayoutAddress, theirPayoutAddress) = {
if (dlcDb.isInitiator) {
val myCollateral = offerDb.collateral
@ -45,4 +43,5 @@ object AccountingUtil {
theirPayout = theirPayout
)
}
}

View File

@ -0,0 +1,17 @@
package org.bitcoins.dlc.wallet.accounting
import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.dlc.wallet.models.{DLCAcceptDb, DLCDb, DLCOfferDb}
case class DLCAccountingDbs(
dlcDb: DLCDb,
offerDb: DLCOfferDb,
acceptDb: DLCAcceptDb,
closingTx: Transaction) {
require(
dlcDb.dlcId == offerDb.dlcId,
s"dlcDb.dlcId not equal to offerDb.dlcId, got dlcDb.dlcId=${dlcDb.dlcId} offerDb.dlcId=${offerDb.dlcId}")
require(
offerDb.dlcId == acceptDb.dlcId,
s"OfferDb and acceptDb not the same offerDb.dlcId=${offerDb.dlcId}, acceptDb.dlcId=${acceptDb.dlcId}")
}

View File

@ -8,6 +8,7 @@ import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.crypto.SchnorrDigitalSignature
import org.bitcoins.dlc.wallet.accounting.AccountingUtil
import org.bitcoins.dlc.wallet.models._
import org.bitcoins.dlc.wallet.accounting.DLCAccountingDbs
object DLCStatusBuilder {
@ -114,8 +115,9 @@ object DLCStatusBuilder {
)
val dlcId = dlcDb.dlcId
val financials = DLCAccountingDbs(dlcDb, offerDb, acceptDb, closingTx)
val accounting: DLCAccounting =
AccountingUtil.calculatePnl(dlcDb, offerDb, acceptDb, closingTx)
AccountingUtil.calculatePnl(financials)
val totalCollateral = contractData.totalCollateral