OrderBook can now provide expired orders, if requested.
This commit is contained in:
parent
c3f7ad60f6
commit
906b419fc6
|
@ -60,7 +60,7 @@ market = mango.ensure_market_loaded(context, stub)
|
|||
market_operations = mango.create_market_operations(context, wallet, account, market, dry_run=False)
|
||||
|
||||
print("Initial order book:\n\t", market_operations.load_orderbook())
|
||||
print("Your current orders:\n\t", market_operations.load_my_orders())
|
||||
print("Your current orders:\n\t", market_operations.load_my_orders(include_expired=True))
|
||||
|
||||
# Go on - try to buy 1 SOL-PERP contract for $10.
|
||||
order = mango.Order.from_basic_info(side=mango.Side.BUY,
|
||||
|
@ -74,7 +74,7 @@ print("\n\nSleeping for 10 seconds...")
|
|||
time.sleep(10)
|
||||
|
||||
print("\n\nOrder book (including our new order):\n", market_operations.load_orderbook())
|
||||
print("Your current orders:\n\t", market_operations.load_my_orders())
|
||||
print("Your current orders:\n\t", market_operations.load_my_orders(include_expired=True))
|
||||
|
||||
cancellation_signatures = market_operations.cancel_order(placed_order)
|
||||
print("\n\nCancellation signature:\n\t", cancellation_signatures)
|
||||
|
@ -83,7 +83,7 @@ print("\n\nSleeping for 10 seconds...")
|
|||
time.sleep(10)
|
||||
|
||||
print("\n\nOrder book (without our order):\n", market_operations.load_orderbook())
|
||||
print("Your current orders:\n\t", market_operations.load_my_orders())
|
||||
print("Your current orders:\n\t", market_operations.load_my_orders(include_expired=True))
|
||||
|
||||
```
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ if market is None:
|
|||
market_operations = mango.create_market_operations(
|
||||
context, wallet, account, market, args.dry_run
|
||||
)
|
||||
orders = market_operations.load_my_orders()
|
||||
orders = market_operations.load_my_orders(include_expired=True)
|
||||
if len(orders) == 0:
|
||||
mango.output(f"No open orders on {market.symbol}")
|
||||
else:
|
||||
|
|
|
@ -55,7 +55,7 @@ for account in accounts:
|
|||
filename = f"trade-history-{account.address}.csv"
|
||||
history: mango.TradeHistory = mango.TradeHistory()
|
||||
if args.most_recent_hours:
|
||||
cutoff: datetime = datetime.utcnow() - timedelta(hours=args.most_recent_hours)
|
||||
cutoff: datetime = mango.utc_now() - timedelta(hours=args.most_recent_hours)
|
||||
cutoff = cutoff.replace(tzinfo=timezone(offset=timedelta()))
|
||||
history.download_latest(context, account, cutoff)
|
||||
else:
|
||||
|
|
|
@ -90,7 +90,7 @@ parser.add_argument(
|
|||
parser.add_argument(
|
||||
"--existing-order-time-in-force-tolerance",
|
||||
type=Decimal,
|
||||
default=Decimal("0.001"),
|
||||
default=Decimal(0),
|
||||
help="tolerance in time-in-force when matching existing orders or cancelling/replacing",
|
||||
)
|
||||
parser.add_argument(
|
||||
|
@ -187,7 +187,7 @@ def cleanup(
|
|||
)
|
||||
)
|
||||
cancels: mango.CombinableInstructions = mango.CombinableInstructions.empty()
|
||||
orders = market_operations.load_my_orders()
|
||||
orders = market_operations.load_my_orders(include_expired=True)
|
||||
for order in orders:
|
||||
cancels += market_instruction_builder.build_cancel_order_instructions(
|
||||
order, ok_if_missing=True
|
||||
|
|
|
@ -29,6 +29,12 @@ parser.add_argument(
|
|||
default=False,
|
||||
help="runs as read-only and does not perform any transactions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-expired",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="show all owned transactions, even those that have expired",
|
||||
)
|
||||
args: argparse.Namespace = mango.parse_args(parser)
|
||||
|
||||
context = mango.ContextBuilder.from_command_line_parameters(args)
|
||||
|
@ -45,7 +51,7 @@ if market is None:
|
|||
market_operations = mango.create_market_operations(
|
||||
context, wallet, account, market, args.dry_run
|
||||
)
|
||||
orders = market_operations.load_my_orders()
|
||||
orders = market_operations.load_my_orders(include_expired=args.include_expired)
|
||||
mango.output(f"{len(orders)} order(s) to show.")
|
||||
for order in orders:
|
||||
mango.output(order)
|
||||
|
|
|
@ -67,7 +67,7 @@ try:
|
|||
bid, ask = self.calculate_order_prices(price)
|
||||
buy_quantity, sell_quantity = self.calculate_order_quantities(price, inventory)
|
||||
|
||||
current_orders = self.market_operations.load_my_orders()
|
||||
current_orders = self.market_operations.load_my_orders(include_expired=True)
|
||||
buy_orders = [order for order in current_orders if order.side == mango.Side.BUY]
|
||||
if self.orders_require_action(buy_orders, bid, buy_quantity):
|
||||
self._logger.info("Cancelling BUY orders.")
|
||||
|
|
|
@ -64,6 +64,8 @@ from .createmarketoperations import (
|
|||
create_market_instruction_builder as create_market_instruction_builder,
|
||||
)
|
||||
from .createmarketoperations import create_market_operations as create_market_operations
|
||||
from .datetimes import local_now as local_now
|
||||
from .datetimes import utc_now as utc_now
|
||||
from .encoding import decode_binary as decode_binary
|
||||
from .encoding import encode_binary as encode_binary
|
||||
from .encoding import encode_key as encode_key
|
||||
|
|
|
@ -45,6 +45,7 @@ from solana.rpc.types import (
|
|||
from solana.transaction import Transaction
|
||||
|
||||
from .constants import SOL_DECIMAL_DIVISOR
|
||||
from .datetimes import local_now
|
||||
from .instructionreporter import InstructionReporter
|
||||
from .logmessages import expand_log_messages
|
||||
from .text import indent_collection_as_str
|
||||
|
@ -394,7 +395,7 @@ class TransactionWatcher:
|
|||
self.collector = collector
|
||||
|
||||
def report_on_transaction(self) -> None:
|
||||
started_at: datetime = datetime.now()
|
||||
started_at: datetime = local_now()
|
||||
for pause in [
|
||||
0.1,
|
||||
0.2,
|
||||
|
@ -438,7 +439,7 @@ class TransactionWatcher:
|
|||
):
|
||||
[status] = transaction_response["result"]["value"]
|
||||
if status is not None:
|
||||
delta: timedelta = datetime.now() - started_at
|
||||
delta: timedelta = local_now() - started_at
|
||||
time_taken: float = delta.seconds + delta.microseconds / 1000000
|
||||
|
||||
# value should be a dict that looks like:
|
||||
|
@ -493,7 +494,7 @@ class TransactionWatcher:
|
|||
return
|
||||
time.sleep(pause)
|
||||
|
||||
delta = datetime.now() - started_at
|
||||
delta = local_now() - started_at
|
||||
time_wasted_looking: float = delta.seconds + delta.microseconds / 1000000
|
||||
self.collector.add_transaction(
|
||||
TransactionStatus(
|
||||
|
@ -1163,15 +1164,15 @@ class BetterClient:
|
|||
f"Waiting up to {max_wait_in_seconds} seconds for {transaction_ids}."
|
||||
)
|
||||
all_confirmed: typing.List[str] = []
|
||||
start_time: datetime = datetime.now()
|
||||
start_time: datetime = local_now()
|
||||
cutoff: datetime = start_time + timedelta(seconds=max_wait_in_seconds)
|
||||
for transaction_id in transaction_ids:
|
||||
while datetime.now() < cutoff:
|
||||
while local_now() < cutoff:
|
||||
time.sleep(1)
|
||||
confirmed = self.get_confirmed_transaction(transaction_id)
|
||||
if confirmed is not None:
|
||||
self._logger.info(
|
||||
f"Confirmed {transaction_id} after {datetime.now() - start_time} seconds."
|
||||
f"Confirmed {transaction_id} after {local_now() - start_time} seconds."
|
||||
)
|
||||
all_confirmed += [transaction_id]
|
||||
break
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
# # ⚠ Warning
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
# [🥭 Mango Markets](https://mango.markets/) support is available at:
|
||||
# [Docs](https://docs.mango.markets/)
|
||||
# [Discord](https://discord.gg/67jySBhxrg)
|
||||
# [Twitter](https://twitter.com/mangomarkets)
|
||||
# [Github](https://github.com/blockworks-foundation)
|
||||
# [Email](mailto:hello@blockworks.foundation)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# # 🥭 Datetimes
|
||||
#
|
||||
# This file contains a few useful datetime functions.
|
||||
#
|
||||
# They exist solely to make clear what is being used when called. There are 2 general cases:
|
||||
# 1. Logging/time tracking/user output - local time is what should be used.
|
||||
# 2. Comparison with dates stored on-chain - UTC should be used.
|
||||
#
|
||||
# Getting a UTC time that is comparable with on-chain datetimes isn't intuitive, so putting
|
||||
# it in one place and using that consistently should make it clearer in the calling code
|
||||
# what is going on, without the unnecessary complications.
|
||||
#
|
||||
|
||||
|
||||
def local_now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.utcnow().astimezone(timezone.utc)
|
|
@ -18,7 +18,6 @@ import mango
|
|||
import traceback
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from .hedger import Hedger
|
||||
|
@ -163,7 +162,7 @@ class PerpToSpotHedger(Hedger):
|
|||
)
|
||||
raise
|
||||
|
||||
self.pulse_complete.on_next(datetime.now())
|
||||
self.pulse_complete.on_next(mango.local_now())
|
||||
except (
|
||||
mango.RateLimitException,
|
||||
mango.NodeIsBehindException,
|
||||
|
|
|
@ -24,6 +24,7 @@ from decimal import Decimal
|
|||
from .account import Account
|
||||
from .accountliquidator import AccountLiquidator
|
||||
from .context import Context
|
||||
from .datetimes import local_now
|
||||
from .group import Group
|
||||
from .instrumentvalue import InstrumentValue
|
||||
from .liquidatablereport import LiquidatableReport, LiquidatableState
|
||||
|
@ -79,8 +80,8 @@ class LiquidationProcessor:
|
|||
LiquidationEvent
|
||||
]()
|
||||
self.ripe_accounts: typing.Optional[typing.Sequence[Account]] = None
|
||||
self.ripe_accounts_updated_at: datetime = datetime.now()
|
||||
self.prices_updated_at: datetime = datetime.now()
|
||||
self.ripe_accounts_updated_at: datetime = local_now()
|
||||
self.prices_updated_at: datetime = local_now()
|
||||
self.state: LiquidationProcessorState = LiquidationProcessorState.STARTING
|
||||
self.state_change: EventSource[LiquidationProcessor] = EventSource[
|
||||
LiquidationProcessor
|
||||
|
@ -92,7 +93,7 @@ class LiquidationProcessor:
|
|||
)
|
||||
self._check_update_recency("prices", self.prices_updated_at)
|
||||
self.ripe_accounts = ripe_accounts
|
||||
self.ripe_accounts_updated_at = datetime.now()
|
||||
self.ripe_accounts_updated_at = local_now()
|
||||
# If this is the first time through, mark ourselves as Healthy.
|
||||
if self.state == LiquidationProcessorState.STARTING:
|
||||
self.state = LiquidationProcessorState.HEALTHY
|
||||
|
@ -160,7 +161,7 @@ class LiquidationProcessor:
|
|||
|
||||
self._liquidate_all(group, prices, worthwhile)
|
||||
|
||||
self.prices_updated_at = datetime.now()
|
||||
self.prices_updated_at = local_now()
|
||||
time_taken = time.time() - started_at
|
||||
self._logger.info(
|
||||
f"Check of all ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds."
|
||||
|
@ -215,7 +216,7 @@ class LiquidationProcessor:
|
|||
)
|
||||
|
||||
def _check_update_recency(self, name: str, last_updated_at: datetime) -> None:
|
||||
how_long_ago_was_last_update = datetime.now() - last_updated_at
|
||||
how_long_ago_was_last_update = local_now() - last_updated_at
|
||||
if how_long_ago_was_last_update > LiquidationProcessor._AGE_ERROR_THRESHOLD:
|
||||
self.state = LiquidationProcessorState.UNHEALTHY
|
||||
self.state_change.on_next(self)
|
||||
|
|
|
@ -155,7 +155,7 @@ Ignore:
|
|||
payer + cancellations + place_orders + crank + settle + redeem
|
||||
).execute(context)
|
||||
|
||||
self.pulse_complete.on_next(datetime.now())
|
||||
self.pulse_complete.on_next(mango.local_now())
|
||||
except (
|
||||
mango.RateLimitException,
|
||||
mango.NodeIsBehindException,
|
||||
|
|
|
@ -139,7 +139,7 @@ class MarketOperations(metaclass=abc.ABCMeta):
|
|||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def load_my_orders(self) -> typing.Sequence[Order]:
|
||||
def load_my_orders(self, include_expired: bool = False) -> typing.Sequence[Order]:
|
||||
raise NotImplementedError(
|
||||
"MarketOperations.load_my_orders() is not implemented on the base type."
|
||||
)
|
||||
|
@ -228,7 +228,7 @@ class NullMarketOperations(MarketOperations):
|
|||
def load_orderbook(self) -> OrderBook:
|
||||
return OrderBook(self.market_name, NullLotSizeConverter(), [], [])
|
||||
|
||||
def load_my_orders(self) -> typing.Sequence[Order]:
|
||||
def load_my_orders(self, include_expired: bool = False) -> typing.Sequence[Order]:
|
||||
return []
|
||||
|
||||
def settle(self) -> typing.Sequence[str]:
|
||||
|
|
|
@ -135,9 +135,9 @@ class ModelState:
|
|||
return self.event_queue_watcher.latest.accounts_to_crank
|
||||
|
||||
def current_orders(self) -> typing.Sequence[Order]:
|
||||
self.orderbook
|
||||
all_orders = [*self.bids, *self.asks]
|
||||
return list([o for o in all_orders if o.owner == self.order_owner])
|
||||
return self.orderbook.all_orders_for_owner(
|
||||
self.order_owner, include_expired=True
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"""« ModelState for market '{self.market.symbol}'
|
||||
|
|
|
@ -23,6 +23,7 @@ from datetime import datetime
|
|||
from rx.core.typing import Disposable
|
||||
from rxpy_backpressure import BackPressure
|
||||
|
||||
from .datetimes import local_now
|
||||
from .output import output
|
||||
|
||||
|
||||
|
@ -83,7 +84,7 @@ class TimestampedPrintingObserverSubscriber(PrintingObserverSubscriber):
|
|||
super().__init__(report_no_output)
|
||||
|
||||
def on_next(self, item: typing.Any) -> None:
|
||||
super().on_next(f"{datetime.now()}: {item}")
|
||||
super().on_next(f"{local_now()}: {item}")
|
||||
|
||||
|
||||
# # 🥭 CollectingObserverSubscriber class
|
||||
|
@ -140,11 +141,11 @@ class LatestItemObserverSubscriber(
|
|||
def __init__(self, initial: TItem) -> None:
|
||||
super().__init__()
|
||||
self.latest: TItem = initial
|
||||
self.update_timestamp: datetime = datetime.now()
|
||||
self.update_timestamp: datetime = local_now()
|
||||
|
||||
def on_next(self, item: TItem) -> None:
|
||||
self.latest = item
|
||||
self.update_timestamp = datetime.now()
|
||||
self.update_timestamp = local_now()
|
||||
|
||||
def on_error(self, ex: Exception) -> None:
|
||||
pass
|
||||
|
|
|
@ -25,6 +25,7 @@ from decimal import Decimal
|
|||
from rx.subject.subject import Subject
|
||||
|
||||
from ...context import Context
|
||||
from ...datetimes import utc_now
|
||||
from ...market import Market
|
||||
from ...observables import DisposePropagator, DisposeWrapper
|
||||
from ...oracle import (
|
||||
|
@ -79,7 +80,7 @@ class FtxOracle(Oracle):
|
|||
|
||||
return Price(
|
||||
self.source,
|
||||
datetime.now(),
|
||||
utc_now(),
|
||||
self.market,
|
||||
bid,
|
||||
price,
|
||||
|
|
|
@ -18,10 +18,10 @@ import rx
|
|||
import rx.operators as ops
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from ...context import Context
|
||||
from ...datetimes import utc_now
|
||||
from ...ensuremarketloaded import ensure_market_loaded
|
||||
from ...loadedmarket import LoadedMarket
|
||||
from ...market import Market
|
||||
|
@ -86,7 +86,7 @@ class MarketOracle(Oracle):
|
|||
|
||||
return Price(
|
||||
self.source,
|
||||
datetime.now(),
|
||||
utc_now(),
|
||||
self.market,
|
||||
top_bid,
|
||||
mid_price,
|
||||
|
|
|
@ -19,12 +19,12 @@ import rx
|
|||
import rx.operators as ops
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from solana.publickey import PublicKey
|
||||
|
||||
from ...accountinfo import AccountInfo
|
||||
from ...context import Context
|
||||
from ...datetimes import utc_now
|
||||
from ...market import Market
|
||||
from ...observables import observable_pipeline_error_reporter
|
||||
from ...oracle import (
|
||||
|
@ -110,7 +110,13 @@ class PythOracle(Oracle):
|
|||
|
||||
# Pyth has no notion of bids, asks, or spreads so just provide the single price.
|
||||
return Price(
|
||||
self.source, datetime.now(), self.market, price, price, price, confidence
|
||||
self.source,
|
||||
utc_now(),
|
||||
self.market,
|
||||
price,
|
||||
price,
|
||||
price,
|
||||
confidence,
|
||||
)
|
||||
|
||||
def to_streaming_observable(
|
||||
|
|
|
@ -18,12 +18,12 @@ import rx
|
|||
import rx.operators as ops
|
||||
import typing
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from solana.publickey import PublicKey
|
||||
|
||||
from ...cache import Cache
|
||||
from ...context import Context
|
||||
from ...datetimes import utc_now
|
||||
from ...ensuremarketloaded import ensure_market_loaded
|
||||
from ...market import Market
|
||||
from ...observables import observable_pipeline_error_reporter
|
||||
|
@ -83,7 +83,7 @@ class StubOracle(Oracle):
|
|||
# will give you the consistent results, but you'll need to adjust your code"
|
||||
return Price(
|
||||
self.source,
|
||||
datetime.now(),
|
||||
utc_now(),
|
||||
self.market,
|
||||
raw_price.price,
|
||||
raw_price.price,
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
import enum
|
||||
import typing
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from solana.publickey import PublicKey
|
||||
|
||||
|
@ -141,51 +141,47 @@ class PerpOrderBookSide(AddressableAccount):
|
|||
|
||||
stack = [self.root_node]
|
||||
orders: typing.List[Order] = []
|
||||
now: datetime = datetime.utcnow().astimezone(timezone.utc)
|
||||
while len(stack) > 0:
|
||||
index = int(stack.pop())
|
||||
node = self.nodes[index]
|
||||
if node.type_name == "leaf":
|
||||
expiration = (
|
||||
node.timestamp.astimezone(timezone.utc)
|
||||
+ timedelta(seconds=float(node.time_in_force))
|
||||
if node.time_in_force == 0
|
||||
else Order.NoExpiration
|
||||
expiration = Order.NoExpiration
|
||||
if node.time_in_force != 0:
|
||||
expiration = node.timestamp.astimezone(timezone.utc) + timedelta(
|
||||
seconds=float(node.time_in_force)
|
||||
)
|
||||
|
||||
price = node.key["price"]
|
||||
quantity = node.quantity
|
||||
|
||||
decimals_differential = (
|
||||
self.perp_market_details.base_instrument.decimals
|
||||
- self.perp_market_details.quote_token.token.decimals
|
||||
)
|
||||
if (node.time_in_force == 0) or (expiration > now):
|
||||
price = node.key["price"]
|
||||
quantity = node.quantity
|
||||
native_to_ui = Decimal(10) ** decimals_differential
|
||||
quote_lot_size = self.perp_market_details.quote_lot_size
|
||||
base_lot_size = self.perp_market_details.base_lot_size
|
||||
actual_price = price * (quote_lot_size / base_lot_size) * native_to_ui
|
||||
|
||||
decimals_differential = (
|
||||
self.perp_market_details.base_instrument.decimals
|
||||
- self.perp_market_details.quote_token.token.decimals
|
||||
)
|
||||
native_to_ui = Decimal(10) ** decimals_differential
|
||||
quote_lot_size = self.perp_market_details.quote_lot_size
|
||||
base_lot_size = self.perp_market_details.base_lot_size
|
||||
actual_price = (
|
||||
price * (quote_lot_size / base_lot_size) * native_to_ui
|
||||
)
|
||||
base_factor = (
|
||||
Decimal(10) ** self.perp_market_details.base_instrument.decimals
|
||||
)
|
||||
actual_quantity = (
|
||||
quantity * self.perp_market_details.base_lot_size
|
||||
) / base_factor
|
||||
|
||||
base_factor = (
|
||||
Decimal(10) ** self.perp_market_details.base_instrument.decimals
|
||||
orders += [
|
||||
Order(
|
||||
int(node.key["order_id"]),
|
||||
node.client_order_id,
|
||||
node.owner,
|
||||
order_side,
|
||||
actual_price,
|
||||
actual_quantity,
|
||||
OrderType.UNKNOWN,
|
||||
expiration=expiration,
|
||||
)
|
||||
actual_quantity = (
|
||||
quantity * self.perp_market_details.base_lot_size
|
||||
) / base_factor
|
||||
|
||||
orders += [
|
||||
Order(
|
||||
int(node.key["order_id"]),
|
||||
node.client_order_id,
|
||||
node.owner,
|
||||
order_side,
|
||||
actual_price,
|
||||
actual_quantity,
|
||||
OrderType.UNKNOWN,
|
||||
expiration=expiration,
|
||||
)
|
||||
]
|
||||
]
|
||||
elif node.type_name == "inner":
|
||||
if order_side == Side.BUY:
|
||||
stack = [*stack, node.children[0], node.children[1]]
|
||||
|
|
|
@ -26,6 +26,7 @@ from pyserum.market.types import Order as PySerumOrder
|
|||
from solana.publickey import PublicKey
|
||||
|
||||
from .constants import SYSTEM_PROGRAM_ADDRESS
|
||||
from .datetimes import utc_now
|
||||
from .lotsizeconverter import LotSizeConverter
|
||||
|
||||
|
||||
|
@ -170,6 +171,12 @@ class Order:
|
|||
high_order = id_bytes[8:]
|
||||
return Decimal(int.from_bytes(high_order, "little", signed=False))
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
if (self.expiration == Order.NoExpiration) or (self.expiration > utc_now()):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Returns an identical order with the ID changed.
|
||||
def with_id(self, id: int) -> "Order":
|
||||
return Order(
|
||||
|
@ -388,7 +395,7 @@ class Order:
|
|||
if expire_seconds is None or expire_seconds <= Decimal(0):
|
||||
return Order.NoExpiration
|
||||
|
||||
return datetime.now() + timedelta(seconds=float(expire_seconds))
|
||||
return utc_now() + timedelta(seconds=float(expire_seconds))
|
||||
|
||||
def __str__(self) -> str:
|
||||
owner: str = ""
|
||||
|
@ -397,7 +404,7 @@ class Order:
|
|||
order_type: str = ""
|
||||
if self.order_type != OrderType.UNKNOWN:
|
||||
order_type = f" {self.order_type}"
|
||||
return f"« Order {owner}{self.side} for {self.quantity:,.8f} at {self.price:.8f} [ID: {self.id} / {self.client_id}]{order_type}{' reduceOnly' if self.reduce_only else ''}»"
|
||||
return f"« Order {owner}{self.side} for {self.quantity:,.8f} at {self.price:.8f} [ID: {self.id} / {self.client_id}]{order_type}{' reduceOnly' if self.reduce_only else ''}{' EXPIRED' if self.expired else ''} »"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self}"
|
||||
|
@ -420,7 +427,7 @@ class OrderBook:
|
|||
|
||||
@property
|
||||
def bids(self) -> typing.Sequence[Order]:
|
||||
return self.__bids
|
||||
return list([o for o in self.__bids if not o.expired])
|
||||
|
||||
@bids.setter
|
||||
def bids(self, bids: typing.Sequence[Order]) -> None:
|
||||
|
@ -431,7 +438,7 @@ class OrderBook:
|
|||
|
||||
@property
|
||||
def asks(self) -> typing.Sequence[Order]:
|
||||
return self.__asks
|
||||
return list([o for o in self.__asks if not o.expired])
|
||||
|
||||
@asks.setter
|
||||
def asks(self, asks: typing.Sequence[Order]) -> None:
|
||||
|
@ -443,28 +450,32 @@ class OrderBook:
|
|||
# The top bid is the highest price someone is willing to pay to BUY
|
||||
@property
|
||||
def top_bid(self) -> typing.Optional[Order]:
|
||||
if self.bids and len(self.bids) > 0:
|
||||
bids = self.bids
|
||||
if bids and len(bids) > 0:
|
||||
# Top-of-book is always at index 0 for us.
|
||||
return self.bids[0]
|
||||
return bids[0]
|
||||
return None
|
||||
|
||||
# The top ask is the lowest price someone is willing to pay to SELL
|
||||
@property
|
||||
def top_ask(self) -> typing.Optional[Order]:
|
||||
if self.asks and len(self.asks) > 0:
|
||||
asks = self.asks
|
||||
if asks and len(asks) > 0:
|
||||
# Top-of-book is always at index 0 for us.
|
||||
return self.asks[0]
|
||||
return asks[0]
|
||||
return None
|
||||
|
||||
# The mid price is halfway between the best bid and best ask.
|
||||
@property
|
||||
def mid_price(self) -> typing.Optional[Decimal]:
|
||||
if self.top_bid is not None and self.top_ask is not None:
|
||||
return (self.top_bid.price + self.top_ask.price) / 2
|
||||
elif self.top_bid is not None:
|
||||
return self.top_bid.price
|
||||
elif self.top_ask is not None:
|
||||
return self.top_ask.price
|
||||
top_bid = self.top_bid
|
||||
top_ask = self.top_ask
|
||||
if top_bid is not None and top_ask is not None:
|
||||
return (top_bid.price + top_ask.price) / 2
|
||||
elif top_bid is not None:
|
||||
return top_bid.price
|
||||
elif top_ask is not None:
|
||||
return top_ask.price
|
||||
return None
|
||||
|
||||
@property
|
||||
|
@ -476,8 +487,17 @@ class OrderBook:
|
|||
else:
|
||||
return top_ask.price - top_bid.price
|
||||
|
||||
def all_orders_for_owner(self, owner_address: PublicKey) -> typing.Sequence[Order]:
|
||||
return list([o for o in [*self.bids, *self.asks] if o.owner == owner_address])
|
||||
def all_orders(self, include_expired: bool = False) -> typing.Sequence[Order]:
|
||||
if include_expired:
|
||||
return [*self.__bids, *self.__asks]
|
||||
return [*self.bids, *self.asks]
|
||||
|
||||
def all_orders_for_owner(
|
||||
self, owner_address: PublicKey, include_expired: bool = False
|
||||
) -> typing.Sequence[Order]:
|
||||
return list(
|
||||
[o for o in self.all_orders(include_expired) if o.owner == owner_address]
|
||||
)
|
||||
|
||||
def to_dataframe(self) -> pandas.DataFrame:
|
||||
column_mapper = {
|
||||
|
@ -487,6 +507,7 @@ class OrderBook:
|
|||
"side": "Side",
|
||||
"price": "Price",
|
||||
"quantity": "Quantity",
|
||||
"expiration": "Expiration",
|
||||
}
|
||||
|
||||
frame: pandas.DataFrame = pandas.DataFrame([*reversed(self.bids), *self.asks])
|
||||
|
|
|
@ -15,13 +15,14 @@
|
|||
|
||||
import typing
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from solana.publickey import PublicKey
|
||||
|
||||
from .accountinfo import AccountInfo
|
||||
from .addressableaccount import AddressableAccount
|
||||
from .context import Context
|
||||
from .datetimes import utc_now
|
||||
from .group import GroupSlot, Group
|
||||
from .instrumentvalue import InstrumentValue
|
||||
from .layouts import layouts
|
||||
|
@ -87,7 +88,7 @@ class LiquidityMiningInfo:
|
|||
# portion_given = 1 - mngoLeft / mngoPerPeriod
|
||||
# elapsed = (<current_time> - periodStart) / targetPeriodLength
|
||||
# est_next = elapsed / portion_given - elapsed
|
||||
now: datetime = datetime.now().replace(microsecond=0).astimezone(timezone.utc)
|
||||
now: datetime = utc_now().replace(microsecond=0)
|
||||
mngo_distributed: InstrumentValue = self.mngo_per_period - self.mngo_left
|
||||
proportion_distributed: Decimal = Decimal(0)
|
||||
elapsed: timedelta = now - self.period_start
|
||||
|
|
|
@ -264,9 +264,11 @@ class PerpMarketOperations(MarketOperations):
|
|||
def load_orderbook(self) -> OrderBook:
|
||||
return self.perp_market.fetch_orderbook(self.context)
|
||||
|
||||
def load_my_orders(self) -> typing.Sequence[Order]:
|
||||
def load_my_orders(self, include_expired: bool = False) -> typing.Sequence[Order]:
|
||||
orderbook: OrderBook = self.load_orderbook()
|
||||
return orderbook.all_orders_for_owner(self.account.address)
|
||||
return orderbook.all_orders_for_owner(
|
||||
self.account.address, include_expired=include_expired
|
||||
)
|
||||
|
||||
def _build_crank(
|
||||
self, limit: Decimal = Decimal(32), add_self: bool = False
|
||||
|
|
|
@ -21,9 +21,10 @@ import rx.subject
|
|||
import typing
|
||||
import websocket
|
||||
|
||||
from datetime import datetime
|
||||
from threading import Thread
|
||||
|
||||
from .datetimes import local_now
|
||||
|
||||
|
||||
# # 🥭 ReconnectingWebsocket class
|
||||
#
|
||||
|
@ -41,16 +42,16 @@ class ReconnectingWebsocket:
|
|||
self.reconnect_required: bool = True
|
||||
self.ping_interval: int = 0
|
||||
self.connecting: rx.subject.behaviorsubject.BehaviorSubject = (
|
||||
rx.subject.behaviorsubject.BehaviorSubject(datetime.now())
|
||||
rx.subject.behaviorsubject.BehaviorSubject(local_now())
|
||||
)
|
||||
self.disconnected: rx.subject.behaviorsubject.BehaviorSubject = (
|
||||
rx.subject.behaviorsubject.BehaviorSubject(datetime.now())
|
||||
rx.subject.behaviorsubject.BehaviorSubject(local_now())
|
||||
)
|
||||
self.ping: rx.subject.behaviorsubject.BehaviorSubject = (
|
||||
rx.subject.behaviorsubject.BehaviorSubject(datetime.now())
|
||||
rx.subject.behaviorsubject.BehaviorSubject(local_now())
|
||||
)
|
||||
self.pong: rx.subject.behaviorsubject.BehaviorSubject = (
|
||||
rx.subject.behaviorsubject.BehaviorSubject(datetime.now())
|
||||
rx.subject.behaviorsubject.BehaviorSubject(local_now())
|
||||
)
|
||||
self.item: rx.subject.subject.Subject = rx.subject.subject.Subject()
|
||||
|
||||
|
@ -76,10 +77,10 @@ class ReconnectingWebsocket:
|
|||
self._logger.warning(f"WebSocket for {self.url} has error {args}")
|
||||
|
||||
def _on_ping(self, *_: typing.Any) -> None:
|
||||
self.ping.on_next(datetime.now())
|
||||
self.ping.on_next(local_now())
|
||||
|
||||
def _on_pong(self, *_: typing.Any) -> None:
|
||||
self.pong.on_next(datetime.now())
|
||||
self.pong.on_next(local_now())
|
||||
|
||||
def open(self) -> None:
|
||||
thread = Thread(target=self._run)
|
||||
|
@ -91,7 +92,7 @@ class ReconnectingWebsocket:
|
|||
def _run(self) -> None:
|
||||
while self.reconnect_required:
|
||||
self._logger.info(f"WebSocket connecting to: {self.url}")
|
||||
self.connecting.on_next(datetime.now())
|
||||
self.connecting.on_next(local_now())
|
||||
self._ws = websocket.WebSocketApp(
|
||||
self.url,
|
||||
on_open=self._on_open,
|
||||
|
@ -101,4 +102,4 @@ class ReconnectingWebsocket:
|
|||
on_pong=self._on_pong,
|
||||
)
|
||||
self._ws.run_forever(ping_interval=self.ping_interval)
|
||||
self.disconnected.on_next(datetime.now())
|
||||
self.disconnected.on_next(local_now())
|
||||
|
|
|
@ -357,13 +357,15 @@ class SerumMarketOperations(MarketOperations):
|
|||
def load_orderbook(self) -> OrderBook:
|
||||
return self.serum_market.fetch_orderbook(self.context)
|
||||
|
||||
def load_my_orders(self) -> typing.Sequence[Order]:
|
||||
def load_my_orders(self, include_expired: bool = False) -> typing.Sequence[Order]:
|
||||
open_orders_address = self.market_instruction_builder.open_orders_address
|
||||
if not open_orders_address:
|
||||
return []
|
||||
|
||||
orderbook: OrderBook = self.load_orderbook()
|
||||
return orderbook.all_orders_for_owner(open_orders_address)
|
||||
return orderbook.all_orders_for_owner(
|
||||
open_orders_address, include_expired=include_expired
|
||||
)
|
||||
|
||||
def _build_crank(
|
||||
self, limit: Decimal = Decimal(32), add_self: bool = False
|
||||
|
|
|
@ -99,7 +99,9 @@ class SimpleMarketMaker:
|
|||
price, inventory
|
||||
)
|
||||
|
||||
current_orders = self.market_operations.load_my_orders()
|
||||
current_orders = self.market_operations.load_my_orders(
|
||||
include_expired=True
|
||||
)
|
||||
buy_orders = [
|
||||
order for order in current_orders if order.side == mango.Side.BUY
|
||||
]
|
||||
|
@ -145,7 +147,7 @@ class SimpleMarketMaker:
|
|||
|
||||
def cleanup(self) -> None:
|
||||
self._logger.info("Cleaning up.")
|
||||
orders = self.market_operations.load_my_orders()
|
||||
orders = self.market_operations.load_my_orders(include_expired=True)
|
||||
for order in orders:
|
||||
self.market_operations.cancel_order(order)
|
||||
|
||||
|
@ -228,9 +230,14 @@ class SimpleMarketMaker:
|
|||
|
||||
return len(orders) == 0 or not all(
|
||||
[
|
||||
(within_tolerance(price, order.price, self.existing_order_tolerance))
|
||||
and within_tolerance(
|
||||
quantity, order.quantity, self.existing_order_tolerance
|
||||
(
|
||||
not order.expired
|
||||
and within_tolerance(
|
||||
price, order.price, self.existing_order_tolerance
|
||||
)
|
||||
and within_tolerance(
|
||||
quantity, order.quantity, self.existing_order_tolerance
|
||||
)
|
||||
)
|
||||
for order in orders
|
||||
]
|
||||
|
|
|
@ -355,12 +355,14 @@ class SpotMarketOperations(MarketOperations):
|
|||
def load_orderbook(self) -> OrderBook:
|
||||
return self.spot_market.fetch_orderbook(self.context)
|
||||
|
||||
def load_my_orders(self) -> typing.Sequence[Order]:
|
||||
def load_my_orders(self, include_expired: bool = False) -> typing.Sequence[Order]:
|
||||
if not self.open_orders_address:
|
||||
return []
|
||||
|
||||
orderbook: OrderBook = self.load_orderbook()
|
||||
return orderbook.all_orders_for_owner(self.open_orders_address)
|
||||
return orderbook.all_orders_for_owner(
|
||||
self.open_orders_address, include_expired=include_expired
|
||||
)
|
||||
|
||||
def _build_crank(
|
||||
self, limit: Decimal = Decimal(32), add_self: bool = False
|
||||
|
|
|
@ -18,7 +18,6 @@ import logging
|
|||
import typing
|
||||
import websocket
|
||||
|
||||
from datetime import datetime
|
||||
from rx.subject.behaviorsubject import BehaviorSubject
|
||||
from rx.core.typing import Disposable
|
||||
from solana.publickey import PublicKey
|
||||
|
@ -26,6 +25,7 @@ from solana.rpc.types import RPCResponse
|
|||
|
||||
from .accountinfo import AccountInfo
|
||||
from .context import Context
|
||||
from .datetimes import local_now
|
||||
from .observables import EventSource
|
||||
from .reconnectingwebsocket import ReconnectingWebsocket
|
||||
|
||||
|
@ -61,7 +61,7 @@ class WebSocketSubscription(
|
|||
TSubscriptionInstance
|
||||
]()
|
||||
self.ws: typing.Optional[ReconnectingWebsocket] = None
|
||||
self.pong: BehaviorSubject = BehaviorSubject(datetime.now())
|
||||
self.pong: BehaviorSubject = BehaviorSubject(local_now())
|
||||
self._pong_subscription: typing.Optional[Disposable] = None
|
||||
|
||||
@abc.abstractmethod
|
||||
|
@ -309,7 +309,7 @@ class SharedWebSocketSubscriptionManager(WebSocketSubscriptionManager):
|
|||
def __init__(self, context: Context, ping_interval: int = 10) -> None:
|
||||
super().__init__(context, ping_interval)
|
||||
self.ws: typing.Optional[ReconnectingWebsocket] = None
|
||||
self.pong: BehaviorSubject = BehaviorSubject(datetime.now())
|
||||
self.pong: BehaviorSubject = BehaviorSubject(local_now())
|
||||
self._pong_subscription: typing.Optional[Disposable] = None
|
||||
|
||||
def open(self) -> None:
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import construct
|
||||
import datetime
|
||||
import mango
|
||||
import mango.marketmaking
|
||||
import typing
|
||||
|
||||
from decimal import Decimal
|
||||
from mango.lotsizeconverter import NullLotSizeConverter
|
||||
from pyserum.market.market import Market as PySerumMarket
|
||||
from pyserum.market.state import MarketState as PySerumMarketState
|
||||
from solana.keypair import Keypair
|
||||
|
@ -250,7 +248,7 @@ def fake_price(
|
|||
mango.OracleSource(
|
||||
"test", "test", mango.SupportedOracleFeature.TOP_BID_AND_OFFER, market
|
||||
),
|
||||
datetime.datetime.now(),
|
||||
mango.utc_now(),
|
||||
market,
|
||||
bid,
|
||||
price,
|
||||
|
@ -337,7 +335,7 @@ def fake_root_bank() -> mango.RootBank:
|
|||
[],
|
||||
Decimal(0),
|
||||
Decimal(0),
|
||||
datetime.datetime.now(),
|
||||
mango.utc_now(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -347,7 +345,11 @@ def fake_cache() -> mango.Cache:
|
|||
|
||||
|
||||
def fake_root_bank_cache() -> mango.RootBankCache:
|
||||
return mango.RootBankCache(Decimal(1), Decimal(2), datetime.datetime.now())
|
||||
return mango.RootBankCache(
|
||||
Decimal(1),
|
||||
Decimal(2),
|
||||
mango.utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def fake_group(address: typing.Optional[PublicKey] = None) -> mango.Group:
|
||||
|
@ -467,7 +469,7 @@ def fake_model_state(
|
|||
placed_orders_container = placed_orders_container or fake_placed_orders_container()
|
||||
inventory = inventory or fake_inventory()
|
||||
orderbook = orderbook or mango.OrderBook(
|
||||
"FAKE", NullLotSizeConverter(), fake_bids(), fake_asks()
|
||||
"FAKE", mango.NullLotSizeConverter(), fake_bids(), fake_asks()
|
||||
)
|
||||
event_queue = event_queue or mango.NullEventQueue()
|
||||
group_watcher: mango.ManualUpdateWatcher[mango.Group] = mango.ManualUpdateWatcher(
|
||||
|
|
|
@ -3,7 +3,7 @@ import argparse
|
|||
from ...context import mango
|
||||
from ...fakes import fake_context, fake_model_state, fake_price
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from mango.marketmaking.orderchain.ratioselement import RatiosElement
|
||||
|
@ -45,8 +45,8 @@ def test_uses_specified_order_parameters() -> None:
|
|||
)
|
||||
result = actual.process(context, model_state, [])
|
||||
|
||||
assert result[0].expiration > datetime.now() - timedelta(seconds=1)
|
||||
assert result[0].expiration < datetime.now() + timedelta(seconds=5)
|
||||
assert result[0].expiration > mango.utc_now() - timedelta(seconds=1)
|
||||
assert result[0].expiration < mango.utc_now() + timedelta(seconds=5)
|
||||
assert result[0].match_limit == 15
|
||||
assert result[0].order_type == mango.OrderType.POST_ONLY_SLIDE
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import mango
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from mango.marketmaking.toleranceorderreconciler import ToleranceOrderReconciler
|
||||
|
||||
|
@ -214,7 +214,7 @@ def test_price_outside_negative_tolerance_no_match() -> None:
|
|||
|
||||
|
||||
def test_time_in_force_early_match() -> None:
|
||||
now = datetime.now()
|
||||
now = mango.utc_now()
|
||||
actual = ToleranceOrderReconciler(
|
||||
Decimal("0.001"), Decimal(0), timedelta(seconds=5)
|
||||
)
|
||||
|
@ -232,7 +232,7 @@ def test_time_in_force_early_match() -> None:
|
|||
|
||||
|
||||
def test_time_in_force_too_early_no_match() -> None:
|
||||
now = datetime.now()
|
||||
now = mango.utc_now()
|
||||
actual = ToleranceOrderReconciler(
|
||||
Decimal("0.001"), Decimal(0), timedelta(seconds=5)
|
||||
)
|
||||
|
@ -250,7 +250,7 @@ def test_time_in_force_too_early_no_match() -> None:
|
|||
|
||||
|
||||
def test_time_in_force_late_match() -> None:
|
||||
now = datetime.now()
|
||||
now = mango.utc_now()
|
||||
actual = ToleranceOrderReconciler(
|
||||
Decimal("0.001"), Decimal(0), timedelta(seconds=5)
|
||||
)
|
||||
|
@ -268,7 +268,7 @@ def test_time_in_force_late_match() -> None:
|
|||
|
||||
|
||||
def test_time_in_force_too_late_no_match() -> None:
|
||||
now = datetime.now()
|
||||
now = mango.utc_now()
|
||||
actual = ToleranceOrderReconciler(
|
||||
Decimal("0.001"), Decimal(0), timedelta(seconds=5)
|
||||
)
|
||||
|
|
|
@ -4,7 +4,6 @@ from .context import mango
|
|||
from .fakes import fake_account_info, fake_seeded_public_key
|
||||
from .data import load_cache
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
|
@ -13,7 +12,7 @@ def test_cache_constructor() -> None:
|
|||
meta_data = mango.Metadata(
|
||||
mango.layouts.DATA_TYPE.parse(bytearray(b"\x07")), mango.Version.V1, True
|
||||
)
|
||||
timestamp = datetime.now()
|
||||
timestamp = mango.utc_now()
|
||||
price_cache = [mango.PriceCache(Decimal(26), timestamp)]
|
||||
root_bank_cache = [
|
||||
mango.RootBankCache(Decimal("0.00001"), Decimal("0.00001"), timestamp)
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
from .context import mango
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def test_order_expired() -> None:
|
||||
actual = mango.Order.from_basic_info(
|
||||
side=mango.Side.BUY,
|
||||
price=Decimal(10),
|
||||
quantity=Decimal(20),
|
||||
order_type=mango.OrderType.LIMIT,
|
||||
expiration=mango.utc_now() - timedelta(seconds=1),
|
||||
)
|
||||
|
||||
assert actual.expired
|
||||
|
||||
|
||||
def test_order_not_expired() -> None:
|
||||
actual = mango.Order.from_basic_info(
|
||||
side=mango.Side.BUY,
|
||||
price=Decimal(10),
|
||||
quantity=Decimal(20),
|
||||
order_type=mango.OrderType.LIMIT,
|
||||
expiration=mango.utc_now() + timedelta(seconds=5),
|
||||
)
|
||||
|
||||
assert not actual.expired
|
|
@ -5,7 +5,6 @@ from solana.publickey import PublicKey
|
|||
from .context import mango
|
||||
from .fakes import fake_account_info, fake_seeded_public_key
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
|
@ -85,7 +84,7 @@ class TstFillPE(mango.PerpFillEvent):
|
|||
super().__init__(
|
||||
0,
|
||||
Decimal(0),
|
||||
datetime.now(),
|
||||
mango.utc_now(),
|
||||
mango.Side.BUY,
|
||||
Decimal(1),
|
||||
Decimal(1),
|
||||
|
|
|
@ -39,7 +39,7 @@ def test_root_bank_constructor() -> None:
|
|||
node_bank = fake_seeded_public_key("node bank")
|
||||
deposit_index = Decimal(98765)
|
||||
borrow_index = Decimal(12345)
|
||||
timestamp = datetime.now()
|
||||
timestamp = mango.utc_now()
|
||||
|
||||
actual = mango.RootBank(
|
||||
account_info,
|
||||
|
|
|
@ -26,7 +26,7 @@ def test_transaction_instruction_constructor() -> None:
|
|||
|
||||
|
||||
def test_transaction_scout_constructor() -> None:
|
||||
timestamp: datetime = datetime.now()
|
||||
timestamp: datetime = mango.utc_now()
|
||||
signatures: typing.Sequence[str] = ["Signature1", "Signature2"]
|
||||
succeeded: bool = True
|
||||
group_name: str = "BTC_ETH_USDT"
|
||||
|
|
Loading…
Reference in New Issue