import datetime
from django.db import models, transaction
from django.utils import timezone
from django.contrib.auth.models import User
from decimal import Decimal
import os
import random
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from markets.signals import order_placed, dataset_change, dataset_expired
import time
from markets.log import logger
from enum import Enum
from enumfields.fields import EnumIntegerField
from _datetime import timedelta
import itertools
from itertools import groupby
import json
## Decimal handling
decimal_places = 2
def to_decimal(f):
return Decimal(f).quantize(Decimal(10) ** -decimal_places)
def CurrencyField():
return models.DecimalField(default=0, decimal_places=decimal_places, max_digits=7)
def t():
return timezone.now()
[docs]class MarketType(Enum):
"""
The different types of markets with regard to the market maker that is used.
"""
order_book = 1
parimutuel = 2
msr_maker = 3
def str(ty):
if ty == MarketType.order_book:
return "Order Book"
elif ty == MarketType.parimutuel:
return "Parimutuel"
elif ty == MarketType.msr_maker:
return "MSR Maker"
return str(ty)
[docs]class Interval():
"""
"""
Years = lambda dt: dt.year
Months = lambda dt: Interval.Years(dt) * 12 + dt.month
Days = lambda dt: Interval.Months(dt) * 31 + dt.day
Hours = lambda dt: Interval.Days(dt) * 24 + dt.hour
Minutes = lambda dt: Interval.Hours(dt) * 60 + dt.month
Seconds = lambda dt: Interval.Minutes(dt) * 60 + dt.month
[docs]class Market(models.Model):
name = models.CharField(max_length=255, default='Market name here. ')
description = models.CharField(max_length=1024, default='Market description here. ')
pub_date = models.DateTimeField('Date Published')
type = EnumIntegerField(MarketType, default=MarketType.msr_maker)
def type_string(self):
return MarketType.str(self.type)
def challenge_end(self):
if self.is_active():
return self.active_set().challenge_end()
else:
return None
[docs] def challenge_end_ticks(self):
"""
Gets the challenge_end datetime in the form of ticks elapsed since the Epoch.
"""
t = self.challenge_end()
if t:
return int(time.mktime(t.timetuple()) * 1000)
else:
return None
[docs] def challenge_start_ticks(self):
"""
Gets the challenge_start datetime in the form of ticks elapsed since the Epoch.
"""
t = self.challenge_start()
if t:
return int(time.mktime(t.timetuple()) * 1000)
else:
return None
def challenge_start(self):
if self.is_active():
return self.active_set().challenge_start
else:
return None
[docs] def active_set(self):
"Gets the active dataset for this market, or None if the market is inactive. "
try:
return self.dataset_set.get(market=self, is_active=True)
except ObjectDoesNotExist:
return None
except MultipleObjectsReturned:
raise Exception("Too many active datasets! Invalid state?.. ")
def active_datum(self):
set = self.active_set()
if not set:
return None
return set.active_datum()
[docs] def n_events(self):
"Gets the total amount of events registered with this market. "
return Event.objects.filter(market=self).count()
[docs] def n_datasets(self):
"Gets the total number of datasets for this market. "
return DataSet.objects.filter(market=self).count()
[docs] def primary_account(self, u):
"Returns the user's primary account for this market, or None if they are not registered. "
if u.is_anonymous():
return None
try:
return u.account_set.get(market=self, is_primary=True)
except Account.DoesNotExist:
return None
except MultipleObjectsReturned:
raise Exception("Too many accounts for this user! Invalid state?.. ")
[docs] def create_primary_account(self, u):
"Creates a primary account for the given user. Throws an exception if the account exists. "
assert not self.primary_account(u)
a = Account(user=u, market=self, is_primary=True, funds=100)
a.save()
return a
[docs] def api_accounts(self, u):
"Gets all API accounts created by the given user for this market. "
return u.account_set.filter(is_primary=False)
[docs] def is_active(self):
"Gets whether this market has an active dataset. "
return self.active_set() != None
is_active.boolean = True # show it as an icon in the admin panel
[docs] def parse_bid(self, post):
"""
Parses the data from the given POST request.
Returns a list of tuples containing the amount wagered on each outcome for this market.
"""
pos = []
outcomes = Outcome.objects.filter(event__market=self)
for out in outcomes:
try:
ord_pos = int(post["pos_%i" % (out.id)])
except:
ord_pos = 0
if ord_pos != 0:
pos.append((out, ord_pos))
return pos
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# Sets the pub_date field the first time the object is saved
if self.pub_date == None:
self.pub_date = timezone.now()
super(Market, self).save()
[docs]class Account(models.Model):
"""
Represents a user's bank account for a given market.
Normal users are allowed only a primary account
while admin users can create multiple secondary accounts.
"""
user = models.ForeignKey(User)
market = models.ForeignKey(Market)
funds = CurrencyField()
is_primary = models.BooleanField(default=False)
def all_orders(self):
return self.order_set.all()
@transaction.atomic
[docs] def place_order(self, market, position):
"""
Tries to place an order from this account on the given market with the given positions.
Each position is a tuple of an outcome, and the amount to wager on that outcome.
Returns None if the position list is empty.
"""
if not position:
return None
try:
order = Order.new(market, self, position)
except Exception as err:
raise Exception("failed creating an order: " + str(err))
assert (not order.is_processed())
assert (not order.is_successful)
# sends the order_placed signal.
order_placed.send(sender=self.__class__, order=order)
return order
[docs]class Event(models.Model):
"A set of outcomes for a market. "
name = models.CharField(max_length=255, default='Event name here. ')
description = models.CharField(max_length=1024, default='Event description here. ')
market = models.ForeignKey(Market, related_name="events")
[docs] def normalise_outcomes(self):
"Makes sure all outcomes sum to 1"
# unused
outcomes = list(self.outcomes.all())
pdf_invalid = (self.outcomes.filter(current_price=0).count() > 0)
if pdf_invalid:
n_outcomes = len(outcomes)
for o in self.outcomes.all():
o.current_price = 1 / n_outcomes
o.save()
else:
pdf_total = sum(o.current_price for o in outcomes)
pdf_left = Decimal(1) # make sure we never distribute more than 1
for o in outcomes:
new_price = o.current_price / pdf_total
o.current_price = min(pdf_left, new_price)
pdf_left -= o.current_price
# still possible to get a 0 here
o.save()
[docs] def random_outcome(self):
"Draws a random outcome from this set. "
outcomes = list(self.outcomes.all())
n_outcomes = len(outcomes)
random_outcome_id = random.randint(0, n_outcomes-1) # range is inclusive at both ends
return outcomes[random_outcome_id]
[docs] def price_histogram(self, dt_from, dt_to):
"Gets the price histogram for the event in the given time interval. """
d = dt_from - dt_to
pass
[docs] def activity_histogram(self, dt_from, dt_to, interval):
"""Gets the amount of trades that occured in the given time interval."""
d = dt_from - dt_to
# get the list of positions in the given interval
trades = Position.objects.filter(outcome__event=self)
trades = trades.filter(order__timestamp__gte=dt_from)
trades = trades.filter(order__timestamp__lte = dt_to)
trades = list(trades)
trades.sort(key=lambda p: p.order.timestamp)
first_bin = interval(dt_from)
last_bin = interval(dt_to)
n_bins = last_bin - first_bin + 1
bins = []
i = 0
for bin_id in range(first_bin, last_bin + 1):
counts = 0
while i < len(trades) and interval(trades[i].order.timestamp) == bin_id:
counts += 1
i += 1
bins.append(counts)
return bins
def get_prices(self):
return [(o.buy_offer, o.sell_offer) for o in self.outcomes.all()]
def json_prices(self):
return json.dumps([ (o.name, int(100 * o.current_price)) for o in self.outcomes.all()])
def __str__(self):
return self.name
# represents a single outcome in a multiclass market.
[docs]class Outcome(models.Model):
event = models.ForeignKey(Event, default=1, related_name='outcomes')
name = models.CharField(max_length=255)
description = models.CharField(max_length=1024, default='Outcome description here. ')
# TODO: remove
current_price = CurrencyField()
sell_offer = CurrencyField()
buy_offer = CurrencyField()
def market_balance(self):
return MarketBalance.get(self).amount
def price_tooltip(self):
return "You can buy a single share for %.2f.\nYou can sell a single share for %.2f. " % (self.buy_offer, -self.sell_offer)
def __str__(self):
return self.name + " : " + str(self.current_price)
[docs]class DataSet(models.Model):
# the market this dataset is for
market = models.ForeignKey(Market)
name = models.CharField(max_length=255, default='DataSet name here. ')
description = models.CharField(max_length=1024, default='DataSet description here. ')
# whether the dataset is intended for training
# currently unused in code
is_training = models.BooleanField(default=False)
# whether the data set is active for the given markte
is_active = models.BooleanField(default=False)
# the count of datums
# should not be set manually
datum_count = models.IntegerField(default=0)
# the id of the active datum
active_datum_id = models.IntegerField('Active challenge id', default=0)
# time when the active datum was revealed
challenge_start = models.DateTimeField('Challenge started', default=timezone.datetime(2014,1,1))
# interval between consecutive challenges in days
reveal_interval = models.FloatField('Interval between challenges', default=7)
[docs] def get_datum(self, set_id):
"Gets the datum with the specified set_id. Throws an exception if it does not exist. "
return self.datum_set.get(set_id=set_id)
[docs] def next_id(self):
"""
Retrieves an id for the next datum and advances self.datum_count.
"""
id = self.datum_count
self.datum_count += 1
self.save()
return id
[docs] def next_challenge_in(self):
"""
Gets the time remaining until this challenge ends as a string.
"""
if self.is_active:
return self.challenge_end() - timezone.now()
return "N/A"
[docs] def challenge_duration(self):
"""
Gets a timedelta representation of the duration of a challenge.
"""
return datetime.timedelta(days=self.reveal_interval)
[docs] def challenge_end(self):
"""
Gets the DateTime the active challenge ends at.
If this challenge has a negative duration, it is assumed to continue indefinitely.
"""
if self.reveal_interval < 0:
return datetime.datetime.max
return self.challenge_start + self.challenge_duration()
[docs] def challenge_expired(self):
"""
Gets whether this set's current challenge has expired.
"""
return self.challenge_end() < timezone.now()
[docs] def force_advance(self):
"""
Forces the finalisation of this dataset's current challenge and start the new one.
"""
self.challenge_start = timezone.now() - self.challenge_duration()
self.save()
[docs] def has_data(self):
"""
Gets whether this dataset has any datums.
"""
assert self.datum_set.count() == self.datum_count
assert self.datum_count >= 0
return self.datum_count > 0
[docs] def has_datum(self, id):
"""
Gets whether this DataSet contains a datum with the given id.
"""
try:
d = self.get_datum(id)
except ObjectDoesNotExist:
assert (id >= self.datum_count)
return False
assert (id < self.datum_count)
return True
[docs] def active_datum(self):
"""
Gets the active datum. Throws an exception if it does not exist.
"""
return self.get_datum(self.active_datum_id)
[docs] def get_outcomes(self):
"""
Gets all outcomes defined for this market.
"""
return self.market.outcomes.all()
@transaction.atomic
[docs] def start(self):
"Sets this DataSet as the active set for its market."
# if there's another active dataset it is made inactive
ds = self.market.active_set()
if ds == self:
return
if not self.has_data():
raise Exception("Unable to start a set with no datums. ")
if ds != None:
ds.is_active = False
ds.save()
self.is_active = True
self.challenge_start = timezone.now()
self.save()
def stop(self):
self.is_active = False
self.save()
[docs] def set_challenge(self, i):
"Sets the next challenge. "
if not self.has_datum(i):
raise Exception("No challenge with id %d for set %s!" % (i, self))
self.active_datum_id = i
self.challenge_start = timezone.now()
self.save()
[docs] def reset(self):
"Resets active_datum_id to 0. If there is no datum with id of 0, an exception is thrown. "
if not self.has_data():
raise Exception("Unable to reset a set with no data! ")
assert self.has_datum(0)
self.active_datum_id = 0
self.challenge_start = timezone.now()
self.save()
[docs] def next_challenge_id(self):
"""
Gets the id of the next challenge (datum).
Throws an exception if there is no such datum.
"""
new_id = self.active_datum_id + 1
if not self.has_datum(new_id):
raise Exception("No next challenge!")
return new_id
[docs] def next(self):
"""
Advances this active set to the next datum (challenge) _once_, and raises the dataset_expired signal.
If there is no datum with such id, the set is made inactive.
Returns whether the set is active.
"""
assert self.is_active
# Continue only if there is data in the set
# should not typically arrive here
if not self.has_data():
logger.info("Abruptly ended empty dataset %s (no challenges whatsoever). " % (self.active_datum_id, str(self)))
self.stop()
return
# get the current challenge
try:
old_datum = self.active_datum()
except ObjectDoesNotExist: # log an error and deactive the set
self.stop()
logger.error("Unable to find the active data point with id: %d. Stopping the set. " % (self.active_datum_id))
return
except MultipleObjectsReturned:
raise Exception("Too many points with id: %d! Invalid state?.. " % (self.id))
# see if we actually need to advance to the next challenge.
if not self.challenge_expired():
return
# raise the dataset_expired signal
dataset_expired.send(self.__class__, datum=old_datum)
# grab the next challenge (datum)
try:
self.active_datum_id = self.next_challenge_id()
except: # make it inactive if no next datum (this was the last)
self.is_active = False
logger.info("Ended challenge #%d for dataset %s (no next challenge). " % (self.active_datum_id, str(self)))
self.save()
return
self.challenge_start = self.challenge_end()
logger.info("Started next challenge #%d for dataset %s. Ends at %s" % (self.active_datum_id, str(self), self.challenge_end()))
self.save()
return self.is_active
[docs] def random(self, n_data=10):
"""
Generates some random datums for this dataset.
"""
if n_data <= 0:
raise ValueError("n_datums must be positive!")
for i in range(n_data):
dat = Datum.random(self)
self.save()
# creates a new dataset from a file
# TODO: add schema as param?
def from_file(market, file):
pass
def __str__(self):
return self.name
def save(self, *args, **kwargs):
val = super().save(*args, **kwargs)
dataset_change.send(self.__class__, set=self)
return val
[docs]class Datum(models.Model):
"""
Represents an observation in the prediction market.
Defines a result for each event that was observed in this instance, along with a description specific to the observation.
"""
# the set this data point is part of
data_set = models.ForeignKey(DataSet)
# the test name
name = models.CharField(max_length=255, default="Datum name here")
# the test description
description = models.CharField(max_length=1024, default="Datum description here")
# the consecutive id of the datum in the set
set_id = models.IntegerField()
@staticmethod
[docs] def random(set, name = "", description = ""):
"""
Creates a new datum with random results
for the outcomes of this DataSet, and then saves it
"""
id = set.next_id()
if not name: # generate a placeholder name
name = "%s %s" % (set.description, id)
dat = Datum(
name = name,
description = description,
set_id = id,
data_set = set)
dat.save()
# generate random outcomes for each event
es = set.market.events.all()
for e in es:
o = e.random_outcome()
r = Result(
datum=dat,
outcome=o)
r.save()
return dat
[docs] def is_valid(self):
"Checks whether this datum contains results for all market events. "
market = self.data_set.market
mkt_events = set(market.events.all())
for result in self.result_set.select_related('outcome__event'):
ev = result.outcome.event
if ev not in mkt_events:
return False
return True
def __str__(self):
return "%s'%d" % (self.data_set,self.set_id)
[docs]class Result(models.Model):
"The actual outcome of a given event for the specified datum. "
datum = models.ForeignKey(Datum)
outcome = models.ForeignKey(Outcome)
def event(self):
return self.outcome.event
def __str__(self):
return "(%s) wins %s" % (self.outcome, self.datum)
[docs]class Order(models.Model):
"""A pending or already processed order from a user for some market"""
# the account that made the order
account = models.ForeignKey(Account)
# for which dataset entry (datum) was the bet made
datum = models.ForeignKey(Datum)
# when was the order made
timestamp = models.DateTimeField('Time created')
# whether the order was completed successfully
is_successful = models.BooleanField(default=False)
@staticmethod
[docs] def reset(ev):
"""
Cancels all orders for the given event.
"""
for ord in Position.objects.filter(outcome__event=ev, is_processed=False):
ord.cancel()
[docs] def set_processed(self):
"""Marks all positions in this order as processed. """
for o in self.position_set.all():
o.is_processed = True
o.save()
[docs] def is_processed(self):
"""Returns whether all positions in this order are processed. """
return not self.position_set.filter(is_processed=False).exists()
[docs] def unprocessed_orders():
"Retrieves all unprocessed orders from the database. "
return Order.objects.filter(position__is_processed=False)
[docs] def get_position(self, outcome):
"Gets this order's position for the specified outcome. "
try:
return self.position_set.get(outcome=outcome).amount
except ObjectDoesNotExist:
return 0
except MultipleObjectsReturned:
raise Exception("Too many positions for order %d! Invalid state?.. " % (self.id))
# Gets the order along with a list of the order's positions
# for all selected outcomes.
def get_data(self, outcomes):
return (self, [self.get_position(out) for out in outcomes])
[docs] def group_by_event(self):
"""
Groups all positions in this order by their event.
Returns a dictionary of the events with the list of positions on them as the value.
"""
all_ps = list(self.position_set.all())
get_ev_id = lambda p: p.outcome.event.id
all_ps.sort(key=get_ev_id)
return [(ev,list(ps)) for (ev,ps) in groupby(all_ps, key=lambda p: p.outcome.event)]
# creates a new order for the given account playing on the given market.
@staticmethod
@transaction.atomic
def new_single(market, account, outcome, amount):
return Order.new(market, account, [(outcome,amount)])
@staticmethod
@transaction.atomic
[docs] def new(market, account, positions):
"""
Creates a new order for the specified market by the given account, specifying the provided positions.
"""
# get this market's active challenge
dataset = market.active_set()
if not dataset:
# non-active markets should not be listed
raise Exception("No active dataset for this market!")
datum = dataset.active_datum()
# create the order
order = Order(
datum = datum,
account=account,
timestamp = timezone.now())
order.save()
# and its position
for (out, amount) in positions:
pos = Position.new(order, out, amount)
pos.save()
return order
def market(self):
assert (self.account.market == self.datum.data_set.market)
return self.account.market
@transaction.atomic
def cancel(self):
self.set_processed()
self.is_successful = False
self.save()
# represents a position on a given outcome
# in the context of an order.
[docs]class Position(models.Model):
"A player's position (opinion) about a given outcome as part of an order. "
# the order this position
order = models.ForeignKey(Order)
# what the claim was
outcome = models.ForeignKey(Outcome)
# how much contracts to trade
amount = CurrencyField()
# whether the position is processed
is_processed = models.BooleanField(default=False)
# The price per contract for this position.
# Either set by the market maker when it processes the order
# Or set by the account holder when using an Order Book
contract_price = CurrencyField()
def new(order, outcome, amount):
return Position(
order=order,
outcome=outcome,
amount=amount)
[docs] def split(self, amount, price):
"""
Resolves a part of this position.
Returns the newly created order which partially completes the solution, or None if the position was fulfilled. """
assert amount != 0
assert (amount > 0) ^ (self.amount < 0)
assert (self.price > price) ^ (self.amount < 0)
parent_order = self.order
sum = amount * price
# modify the funds
acc = self.order.account
acc.funds
# and the account balance
if amount == self.amount:
self.is_processed = True
self.save()
return None
else:
# remove the amount from this position
self.amount -= amount
self.save()
# create a new, completed dummy order with the same stats as this one
new_order = Order(account=parent_order.account, datum=parent_order.datum, timestamp=parent_order.timestamp, is_successful = True)
new_order.save()
# add the amount
new_pos = Position(
order=new_order,
outcome=self.outcome,
amount = amount,
contract_price = price,
is_processed=True)
new_pos.save()
return new_order
[docs] def partial_complete(pa, pb):
"""
Attempts to partially complete the given two positions.
In order to do so the positions must be of opposite type, compatible prices and on the same outcome.
The price at which the deal is completed is taken as the average of each player's suggested price.
The amount of shares exchanged depends on the amounts defined by each position.
In case one of the positions can be completed only partially, the amount of traded shares are subtracted from it
and the trade is completed by creating a new Order which contains the fulfilled part of the transaction.
"""
# make sure a sells, b buys
if pa.amount > 0:
pa, pb = pb, pa
assert pb.amount > 0
assert pa.outcome == pb.outcome
assert (pb.contract_price >= pa.amount)
# get the contract price at which the deal is made
# if the prices differ, takes their average
if pb.contract_price != pa.contract_price:
price = (pb.contract_price + pa.contract_price) / 2
else:
price = pa.contract_price
# get the amount and total sum for the current deal
amount = min(-pa.amount, pb.amount)
total_sum = price * amount
# modify the accounts' funds
a_acc = pa.order.account
a_acc.funds += total_sum
a_acc.save()
b_acc = pb.order.account
b_acc.funds -= total_sum
b_acc.save()
# modify the account balances
a_balance = AccountBalance.get(a_acc, self.outcome)
a_balance.amount -= amount
b_balance = AccountBalance.get(b_acc, self.outcome)
b_balance.amount -= amount
# make sure partial orders are properly processed
if amount == pb.amount:
# the buyer's position was processed
pb.is_processed = True
pb.save()
# split position a
pa.split(amount, price)
else:
# the seller's position was processed
pa.is_processed = True
pa.save()
# split position b
pb.split(amount, price)
return amount
# gets the total cost for this position.
def get_cost(self):
return self.amount * self.price
def __str__(self):
return "%d tokens for %s" % (self.amount, self.outcome)
# contains uploaded documents to be used as dataset sources.
[docs]class Document(models.Model):
"A document uploaded by the user to be potentially used as a dataset source. "
user = models.ForeignKey(User)
file = models.FileField(upload_to='uploads')
def fileName(self):
return os.path.basename(self.file.name)
def exists(self):
return os.path.isfile(self.file.name)
# removes non-existant files
def clean_db_records(u):
docs = Document.objects.filter(user=u)
for d in docs:
if not d.exists():
d.delete()
def __str__(self):
return self.fileName()
[docs]class AccountBalance(models.Model):
"""
Represents the current holdings of an account for a given outcome (variable).
Contains the amount of shares held by present by the account owner.
"""
account = models.ForeignKey(Account)
outcome = models.ForeignKey(Outcome)
amount = CurrencyField()
@staticmethod
[docs] def get(acc, outcome):
"Gets or creates the supply for the given account. "
try:
supply = outcome.accountbalance_set.get(account=acc)
except models.ObjectDoesNotExist:
supply = AccountBalance(account=acc, outcome=outcome)
supply.save()
except MultipleObjectsReturned:
raise Exception("Too many supplies for account '%s'! Invalid state?.. " % (acc))
return supply
def get_all(outcome):
pass
@staticmethod
[docs] def reset(ev):
"""
Resets the account balances for all players and outcomes in the given event.
"""
for out in ev.outcomes.all():
for acc in out.accountbalance_set.all():
acc.amount = 0
acc.save()
@staticmethod
@transaction.atomic
def accept_order(order):
acc = order.account
for pos in order.position_set.all():
supply = AccountBalance.get(acc, pos.outcome)
supply.amount += pos.amount
supply.save()
[docs]class MarketBalance(models.Model):
"""
The current market supply for a given outcome.
"""
outcome = models.OneToOneField(Outcome)
amount = CurrencyField()
@staticmethod
[docs] def get(outcome):
"Gets or creates the supply for the given outcome. "
try: # try to fetch an existing supply instance
supply = outcome.marketbalance
except models.ObjectDoesNotExist:
supply = MarketBalance(outcome=outcome)
supply.save()
except MultipleObjectsReturned:
raise Exception("Too many supplies for a single outcome! Invalid state?.. ")
return supply
@staticmethod
[docs] def for_event(ev):
"Gets the market maker supplies for all outcomes in the given event. "
return { out: MarketBalance.get(out).amount for out in ev.outcomes.all() }
@staticmethod
[docs] def reset(ev):
"""
Resets the market maker's balance for all outcomes in the given event.
"""
for out in ev.outcomes.all():
try:
supply = out.marketbalance
except:
pass
else:
supply.amount = 0
supply.save()
@staticmethod
@transaction.atomic
[docs] def accept_order(order):
"""Adds the amounts from the given order to the market supply. """
acc = order.account
for pos in order.position_set.all():
supply = MarketBalance.get(pos.outcome)
supply.amount += pos.amount
supply.save()