added txo_plot command to allow plotting txo sums over time

This commit is contained in:
Lex Berezhny 2020-03-26 00:37:13 -04:00
parent 76fa86d54b
commit e5bf6a5bfc
8 changed files with 172 additions and 10 deletions

View file

@ -4283,6 +4283,62 @@ class Daemon(metaclass=JSONRPCServerType):
read_only=True, **self._constrain_txo_from_kwargs({}, **kwargs) read_only=True, **self._constrain_txo_from_kwargs({}, **kwargs)
) )
@requires(WALLET_COMPONENT)
def jsonrpc_txo_plot(
self, account_id=None, wallet_id=None,
days_back=0, start_day=None, days_after=None, end_day=None, **kwargs):
"""
Plot transaction output sum over days.
Usage:
txo_plot [--account_id=<account_id>] [--type=<type>...] [--txid=<txid>...]
[--claim_id=<claim_id>...] [--name=<name>...] [--unspent]
[--is_my_input_or_output |
[[--is_my_output | --is_not_my_output] [--is_my_input | --is_not_my_input]]
]
[--exclude_internal_transfers] [--wallet_id=<wallet_id>]
[--days_back=<days_back> |
[--start_day=<start_day> [--days_after=<days_after> | --end_day=<end_day>]]
]
Options:
--type=<type> : (str or list) claim type: stream, channel, support,
purchase, collection, repost, other
--txid=<txid> : (str or list) transaction id of outputs
--claim_id=<claim_id> : (str or list) claim id
--name=<name> : (str or list) claim name
--unspent : (bool) hide spent outputs, show only unspent ones
--is_my_input_or_output : (bool) txos which have your inputs or your outputs,
if using this flag the other related flags
are ignored (--is_my_output, --is_my_input, etc)
--is_my_output : (bool) show outputs controlled by you
--is_not_my_output : (bool) show outputs not controlled by you
--is_my_input : (bool) show outputs created by you
--is_not_my_input : (bool) show outputs not created by you
--exclude_internal_transfers: (bool) excludes any outputs that are exactly this combination:
"--is_my_input --is_my_output --type=other"
this allows to exclude "change" payments, this
flag can be used in combination with any of the other flags
--account_id=<account_id> : (str) id of the account to query
--wallet_id=<wallet_id> : (str) restrict results to specific wallet
--days_back=<days_back> : (int) number of days back from today
(not compatible with --start_day, --days_after, --end_day)
--start_day=<start_day> : (date) start on specific date (YYYY-MM-DD)
(instead of --days_back)
--days_after=<days_after> : (int) end number of days after --start_day
(instead of --end_day)
--end_day=<end_day> : (date) end on specific date (YYYY-MM-DD)
(instead of --days_after)
Returns: List[Dict]
"""
wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
return self.ledger.get_txo_plot(
wallet=wallet, accounts=[wallet.get_account_or_error(account_id)] if account_id else wallet.accounts,
read_only=True, days_back=days_back, start_day=start_day, days_after=days_after, end_day=end_day,
**self._constrain_txo_from_kwargs({}, **kwargs)
)
UTXO_DOC = """ UTXO_DOC = """
Unspent transaction management. Unspent transaction management.
""" """

View file

@ -580,6 +580,9 @@ class CommandTestCase(IntegrationTestCase):
async def txo_sum(self, *args, **kwargs): async def txo_sum(self, *args, **kwargs):
return await self.out(self.daemon.jsonrpc_txo_sum(*args, **kwargs)) return await self.out(self.daemon.jsonrpc_txo_sum(*args, **kwargs))
async def txo_plot(self, *args, **kwargs):
return await self.out(self.daemon.jsonrpc_txo_plot(*args, **kwargs))
async def claim_list(self, *args, **kwargs): async def claim_list(self, *args, **kwargs):
return (await self.out(self.daemon.jsonrpc_claim_list(*args, **kwargs)))['items'] return (await self.out(self.daemon.jsonrpc_claim_list(*args, **kwargs)))['items']

View file

@ -9,10 +9,12 @@ from contextvars import ContextVar
from concurrent.futures.thread import ThreadPoolExecutor from concurrent.futures.thread import ThreadPoolExecutor
from concurrent.futures.process import ProcessPoolExecutor from concurrent.futures.process import ProcessPoolExecutor
from typing import Tuple, List, Union, Callable, Any, Awaitable, Iterable, Dict, Optional from typing import Tuple, List, Union, Callable, Any, Awaitable, Iterable, Dict, Optional
from datetime import date
from .bip32 import PubKey from .bip32 import PubKey
from .transaction import Transaction, Output, OutputScript, TXRefImmutable from .transaction import Transaction, Output, OutputScript, TXRefImmutable
from .constants import TXO_TYPES, CLAIM_TYPES from .constants import TXO_TYPES, CLAIM_TYPES
from .util import date_to_julian_day
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -254,6 +256,7 @@ def query(select, **constraints) -> Tuple[str, Dict[str, Any]]:
limit = constraints.pop('limit', None) limit = constraints.pop('limit', None)
offset = constraints.pop('offset', None) offset = constraints.pop('offset', None)
order_by = constraints.pop('order_by', None) order_by = constraints.pop('order_by', None)
group_by = constraints.pop('group_by', None)
accounts = constraints.pop('accounts', []) accounts = constraints.pop('accounts', [])
if accounts: if accounts:
@ -264,6 +267,9 @@ def query(select, **constraints) -> Tuple[str, Dict[str, Any]]:
sql.append('WHERE') sql.append('WHERE')
sql.append(where) sql.append(where)
if group_by is not None:
sql.append(f'GROUP BY {group_by}')
if order_by: if order_by:
sql.append('ORDER BY') sql.append('ORDER BY')
if isinstance(order_by, str): if isinstance(order_by, str):
@ -387,7 +393,7 @@ def dict_row_factory(cursor, row):
class Database(SQLiteMixin): class Database(SQLiteMixin):
SCHEMA_VERSION = "1.2" SCHEMA_VERSION = "1.3"
PRAGMAS = """ PRAGMAS = """
pragma journal_mode=WAL; pragma journal_mode=WAL;
@ -422,7 +428,8 @@ class Database(SQLiteMixin):
height integer not null, height integer not null,
position integer not null, position integer not null,
is_verified boolean not null default 0, is_verified boolean not null default 0,
purchased_claim_id text purchased_claim_id text,
day integer
); );
create index if not exists tx_purchased_claim_id_idx on tx (purchased_claim_id); create index if not exists tx_purchased_claim_id_idx on tx (purchased_claim_id);
""" """
@ -506,14 +513,14 @@ class Database(SQLiteMixin):
row['claim_name'] = txo.claim_name row['claim_name'] = txo.claim_name
return row return row
@staticmethod def tx_to_row(self, tx):
def tx_to_row(tx):
row = { row = {
'txid': tx.id, 'txid': tx.id,
'raw': sqlite3.Binary(tx.raw), 'raw': sqlite3.Binary(tx.raw),
'height': tx.height, 'height': tx.height,
'position': tx.position, 'position': tx.position,
'is_verified': tx.is_verified 'is_verified': tx.is_verified,
'day': tx.get_julian_day(self.ledger),
} }
txos = tx.outputs txos = tx.outputs
if len(txos) >= 2 and txos[1].can_decode_purchase_data: if len(txos) >= 2 and txos[1].can_decode_purchase_data:
@ -863,6 +870,7 @@ class Database(SQLiteMixin):
return txos return txos
def _clean_txo_constraints_for_aggregation(self, unspent, constraints): def _clean_txo_constraints_for_aggregation(self, unspent, constraints):
constraints.pop('include_is_spent', None)
constraints.pop('include_is_my_input', None) constraints.pop('include_is_my_input', None)
constraints.pop('include_is_my_output', None) constraints.pop('include_is_my_output', None)
constraints.pop('include_received_tips', None) constraints.pop('include_received_tips', None)
@ -876,14 +884,36 @@ class Database(SQLiteMixin):
async def get_txo_count(self, unspent=False, **constraints): async def get_txo_count(self, unspent=False, **constraints):
self._clean_txo_constraints_for_aggregation(unspent, constraints) self._clean_txo_constraints_for_aggregation(unspent, constraints)
count = await self.select_txos('COUNT(*) as total', **constraints) count = await self.select_txos('COUNT(*) AS total', **constraints)
return count[0]['total'] or 0 return count[0]['total'] or 0
async def get_txo_sum(self, unspent=False, **constraints): async def get_txo_sum(self, unspent=False, **constraints):
self._clean_txo_constraints_for_aggregation(unspent, constraints) self._clean_txo_constraints_for_aggregation(unspent, constraints)
result = await self.select_txos('SUM(amount) as total', **constraints) result = await self.select_txos('SUM(amount) AS total', **constraints)
return result[0]['total'] or 0 return result[0]['total'] or 0
async def get_txo_plot(
self, unspent=False, start_day=None, days_back=0, end_day=None, days_after=None, **constraints):
self._clean_txo_constraints_for_aggregation(unspent, constraints)
if start_day is None:
constraints['day__gte'] = self.ledger.headers.estimated_julian_day(
self.ledger.headers.height
) - days_back
else:
constraints['day__gte'] = date_to_julian_day(
date.fromisoformat(start_day)
)
if end_day is not None:
constraints['day__lte'] = date_to_julian_day(
date.fromisoformat(end_day)
)
elif days_after is not None:
constraints['day__lte'] = constraints['day__gte'] + days_after
return await self.select_txos(
"DATE(day) AS day, SUM(amount) AS total",
group_by='day', order_by='day', **constraints
)
def get_utxos(self, read_only=False, **constraints): def get_utxos(self, read_only=False, **constraints):
return self.get_txos(unspent=True, read_only=read_only, **constraints) return self.get_txos(unspent=True, read_only=read_only, **constraints)

View file

@ -4,6 +4,7 @@ import struct
import asyncio import asyncio
import logging import logging
import zlib import zlib
from datetime import date
from concurrent.futures.thread import ThreadPoolExecutor from concurrent.futures.thread import ThreadPoolExecutor
from io import BytesIO from io import BytesIO
@ -11,7 +12,7 @@ from typing import Optional, Iterator, Tuple, Callable
from binascii import hexlify, unhexlify from binascii import hexlify, unhexlify
from lbry.crypto.hash import sha512, double_sha256, ripemd160 from lbry.crypto.hash import sha512, double_sha256, ripemd160
from lbry.wallet.util import ArithUint256 from lbry.wallet.util import ArithUint256, date_to_julian_day
from .checkpoints import HASHES from .checkpoints import HASHES
@ -129,6 +130,9 @@ class Headers:
def estimated_timestamp(self, height): def estimated_timestamp(self, height):
return self.first_block_timestamp + (height * self.timestamp_average_offset) return self.first_block_timestamp + (height * self.timestamp_average_offset)
def estimated_julian_day(self, height):
return date_to_julian_day(date.fromtimestamp(self.estimated_timestamp(height)))
async def get_raw_header(self, height) -> bytes: async def get_raw_header(self, height) -> bytes:
if self.chunk_getter: if self.chunk_getter:
await self.ensure_chunk_at(height) await self.ensure_chunk_at(height)

View file

@ -277,6 +277,9 @@ class Ledger(metaclass=LedgerRegistry):
def get_txo_sum(self, **constraints): def get_txo_sum(self, **constraints):
return self.db.get_txo_sum(**constraints) return self.db.get_txo_sum(**constraints)
def get_txo_plot(self, **constraints):
return self.db.get_txo_plot(**constraints)
def get_transactions(self, **constraints): def get_transactions(self, **constraints):
return self.db.get_transactions(**constraints) return self.db.get_transactions(**constraints)

View file

@ -4,6 +4,7 @@ import logging
import typing import typing
from binascii import hexlify, unhexlify from binascii import hexlify, unhexlify
from typing import List, Iterable, Optional, Tuple from typing import List, Iterable, Optional, Tuple
from datetime import date
import ecdsa import ecdsa
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -494,7 +495,7 @@ class Output(InputOutput):
class Transaction: class Transaction:
def __init__(self, raw=None, version: int = 1, locktime: int = 0, is_verified: bool = False, def __init__(self, raw=None, version: int = 1, locktime: int = 0, is_verified: bool = False,
height: int = -2, position: int = -1) -> None: height: int = -2, position: int = -1, julian_day: int = None) -> None:
self._raw = raw self._raw = raw
self._raw_sans_segwit = None self._raw_sans_segwit = None
self.is_segwit_flag = 0 self.is_segwit_flag = 0
@ -512,6 +513,7 @@ class Transaction:
# +num: confirmed in a specific block (height) # +num: confirmed in a specific block (height)
self.height = height self.height = height
self.position = position self.position = position
self._day = julian_day
if raw is not None: if raw is not None:
self._deserialize() self._deserialize()
@ -535,6 +537,11 @@ class Transaction:
def hash(self): def hash(self):
return self.ref.hash return self.ref.hash
def get_julian_day(self, ledger):
if self._day is None and self.height > 0:
self._day = ledger.headers.estimated_julian_day(self.height)
return self._day
@property @property
def raw(self): def raw(self):
if self._raw is None: if self._raw is None:

View file

@ -3,6 +3,10 @@ from typing import TypeVar, Sequence, Optional
from .constants import COIN from .constants import COIN
def date_to_julian_day(d):
return d.toordinal() + 1721424.5
def coins_to_satoshis(coins): def coins_to_satoshis(coins):
if not isinstance(coins, str): if not isinstance(coins, str):
raise ValueError("{coins} must be a string") raise ValueError("{coins} must be a string")

View file

@ -9,7 +9,7 @@ from lbry.error import InsufficientFundsError
from lbry.extras.daemon.daemon import DEFAULT_PAGE_SIZE from lbry.extras.daemon.daemon import DEFAULT_PAGE_SIZE
from lbry.testcase import CommandTestCase from lbry.testcase import CommandTestCase
from lbry.wallet.transaction import Transaction from lbry.wallet.transaction import Transaction
from lbry.wallet.util import satoshis_to_coins as lbc from lbry.wallet.util import satoshis_to_coins as lbc, coins_to_satoshis as dewies
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -555,6 +555,61 @@ class TransactionOutputCommands(ClaimTestCase):
r = await self.txo_list(is_not_my_output=True) r = await self.txo_list(is_not_my_output=True)
self.assertEqual([sent_channel], r) self.assertEqual([sent_channel], r)
async def test_txo_plot(self):
day_blocks = int((24 * 60 * 60) / self.ledger.headers.timestamp_average_offset)
stream_id = self.get_claim_id(await self.stream_create())
await self.support_create(stream_id, '0.3')
await self.support_create(stream_id, '0.2')
await self.generate(day_blocks)
await self.support_create(stream_id, '0.4')
await self.support_create(stream_id, '0.5')
await self.generate(day_blocks)
await self.support_create(stream_id, '0.6')
plot = await self.txo_plot(type='support')
self.assertEqual([
{'day': '2016-06-25', 'total': dewies('0.6')},
], plot)
plot = await self.txo_plot(type='support', days_back=1)
self.assertEqual([
{'day': '2016-06-24', 'total': dewies('0.9')},
{'day': '2016-06-25', 'total': dewies('0.6')},
], plot)
plot = await self.txo_plot(type='support', days_back=2)
self.assertEqual([
{'day': '2016-06-23', 'total': dewies('0.5')},
{'day': '2016-06-24', 'total': dewies('0.9')},
{'day': '2016-06-25', 'total': dewies('0.6')},
], plot)
plot = await self.txo_plot(type='support', start_day='2016-06-23')
self.assertEqual([
{'day': '2016-06-23', 'total': dewies('0.5')},
{'day': '2016-06-24', 'total': dewies('0.9')},
{'day': '2016-06-25', 'total': dewies('0.6')},
], plot)
plot = await self.txo_plot(type='support', start_day='2016-06-24')
self.assertEqual([
{'day': '2016-06-24', 'total': dewies('0.9')},
{'day': '2016-06-25', 'total': dewies('0.6')},
], plot)
plot = await self.txo_plot(type='support', start_day='2016-06-23', end_day='2016-06-24')
self.assertEqual([
{'day': '2016-06-23', 'total': dewies('0.5')},
{'day': '2016-06-24', 'total': dewies('0.9')},
], plot)
plot = await self.txo_plot(type='support', start_day='2016-06-23', days_after=1)
self.assertEqual([
{'day': '2016-06-23', 'total': dewies('0.5')},
{'day': '2016-06-24', 'total': dewies('0.9')},
], plot)
plot = await self.txo_plot(type='support', start_day='2016-06-23', days_after=2)
self.assertEqual([
{'day': '2016-06-23', 'total': dewies('0.5')},
{'day': '2016-06-24', 'total': dewies('0.9')},
{'day': '2016-06-25', 'total': dewies('0.6')},
], plot)
class ClaimCommands(ClaimTestCase): class ClaimCommands(ClaimTestCase):