Improve navigating trades and positions by cross-linking

This commit is contained in:
Mark Veidemanis 2022-11-29 07:20:39 +00:00
parent 4e1b574921
commit f240c4b381
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
13 changed files with 236 additions and 34 deletions

View File

@ -105,6 +105,11 @@ urlpatterns = [
trades.TradeUpdate.as_view(), trades.TradeUpdate.as_view(),
name="trade_update", name="trade_update",
), ),
path(
"trades/<str:type>/view/<str:trade_id>/",
trades.TradeAction.as_view(),
name="trade_action",
),
path( path(
"trades/<str:type>/delete/<str:pk>/", "trades/<str:type>/delete/<str:pk>/",
trades.TradeDelete.as_view(), trades.TradeDelete.as_view(),

View File

@ -131,7 +131,6 @@ class BaseExchange(ABC):
def validate_response(self, response, method): def validate_response(self, response, method):
schema = self.get_schema(method) schema = self.get_schema(method)
# Return a dict of the validated response # Return a dict of the validated response
print("RESP", response)
response_valid = schema(**response).dict() response_valid = schema(**response).dict()
return response_valid return response_valid

View File

@ -1,5 +1,5 @@
from oandapyV20 import API from oandapyV20 import API
from oandapyV20.endpoints import accounts, orders, positions, pricing from oandapyV20.endpoints import accounts, orders, positions, pricing, trades
from core.exchanges import BaseExchange from core.exchanges import BaseExchange
@ -52,8 +52,6 @@ class OANDAExchange(BaseExchange):
data = { data = {
"order": { "order": {
# "price": "1.5000", - added later # "price": "1.5000", - added later
"stopLossOnFill": {"timeInForce": "GTC", "price": str(trade.stop_loss)},
"takeProfitOnFill": {"price": str(trade.take_profit)},
"timeInForce": trade.time_in_force.upper(), "timeInForce": trade.time_in_force.upper(),
"instrument": trade.symbol, "instrument": trade.symbol,
"units": str(amount), "units": str(amount),
@ -61,6 +59,13 @@ class OANDAExchange(BaseExchange):
"positionFill": "DEFAULT", "positionFill": "DEFAULT",
} }
} }
if trade.stop_loss is not None:
data["order"]["stopLossOnFill"] = {
"timeInForce": "GTC",
"price": str(trade.stop_loss),
}
if trade.take_profit is not None:
data["order"]["takeProfitOnFill"] = {"price": str(trade.take_profit)}
if trade.price is not None: if trade.price is not None:
if trade.type == "limit": if trade.type == "limit":
data["order"]["price"] = str(trade.price) data["order"]["price"] = str(trade.price)
@ -75,13 +80,14 @@ class OANDAExchange(BaseExchange):
response = self.call(r) response = self.call(r)
trade.response = response trade.response = response
trade.status = "posted" trade.status = "posted"
trade.order_id = response["id"] trade.order_id = str(int(response["id"]) + 1)
trade.client_order_id = response["requestID"] trade.client_order_id = response["requestID"]
trade.save() trade.save()
return response return response
def get_trade(self, trade_id): def get_trade(self, trade_id):
r = accounts.TradeDetails(accountID=self.account_id, tradeID=trade_id) # OANDA is off by one...
r = trades.TradeDetails(accountID=self.account_id, tradeID=trade_id)
return self.call(r) return self.call(r)
def update_trade(self, trade): def update_trade(self, trade):

View File

@ -49,7 +49,7 @@ class OpenPositions(BaseModel):
def parse_prices(x): def parse_prices(x):
if float(x["long"]["units"]) > 0: if float(x["long"]["units"]) > 0:
return x["long"]["averagePrice"] return x["long"]["averagePrice"]
elif float(x["short"]["units"]) > 0: elif float(x["short"]["units"]) < 0:
return x["short"]["averagePrice"] return x["short"]["averagePrice"]
else: else:
return 0 return 0
@ -58,7 +58,7 @@ def parse_prices(x):
def parse_units(x): def parse_units(x):
if float(x["long"]["units"]) > 0: if float(x["long"]["units"]) > 0:
return x["long"]["units"] return x["long"]["units"]
elif float(x["short"]["units"]) > 0: elif float(x["short"]["units"]) < 0:
return x["short"]["units"] return x["short"]["units"]
else: else:
return 0 return 0
@ -67,7 +67,7 @@ def parse_units(x):
def parse_value(x): def parse_value(x):
if float(x["long"]["units"]) > 0: if float(x["long"]["units"]) > 0:
return D(x["long"]["units"]) * D(x["long"]["averagePrice"]) return D(x["long"]["units"]) * D(x["long"]["averagePrice"])
elif float(x["short"]["units"]) > 0: elif float(x["short"]["units"]) < 0:
return D(x["short"]["units"]) * D(x["short"]["averagePrice"]) return D(x["short"]["units"]) * D(x["short"]["averagePrice"])
else: else:
return 0 return 0
@ -76,12 +76,21 @@ def parse_value(x):
def parse_side(x): def parse_side(x):
if float(x["long"]["units"]) > 0: if float(x["long"]["units"]) > 0:
return "long" return "long"
elif float(x["short"]["units"]) > 0: elif float(x["short"]["units"]) < 0:
return "short" return "short"
else: else:
return "unknown" return "unknown"
def parse_trade_ids(x, sum=-1):
if float(x["long"]["units"]) > 0:
return [str(int(y) + sum) for y in x["long"]["tradeIDs"]]
elif float(x["short"]["units"]) < 0:
return [str(int(y) + sum) for y in x["short"]["tradeIDs"]]
else:
return "unknown"
OpenPositionsSchema = { OpenPositionsSchema = {
"itemlist": ( "itemlist": (
"positions", "positions",
@ -89,6 +98,7 @@ OpenPositionsSchema = {
{ {
"symbol": "instrument", "symbol": "instrument",
"unrealized_pl": "unrealizedPL", "unrealized_pl": "unrealizedPL",
"trade_ids": parse_trade_ids, # actual value is lower by 1
"price": parse_prices, "price": parse_prices,
"units": parse_units, "units": parse_units,
"side": parse_side, "side": parse_side,
@ -284,6 +294,9 @@ PositionDetailsSchema = {
"units": lambda x: parse_units(x["position"]), "units": lambda x: parse_units(x["position"]),
"side": lambda x: parse_side(x["position"]), "side": lambda x: parse_side(x["position"]),
"value": lambda x: parse_value(x["position"]), "value": lambda x: parse_value(x["position"]),
"trade_ids": lambda x: parse_trade_ids(
x["position"], sum=0
), # this value is correct
} }

View File

@ -207,6 +207,20 @@ class Trade(models.Model):
# close the trade # close the trade
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
@classmethod
def get_by_id(cls, trade_id, user):
return cls.objects.get(id=trade_id, user=user)
@classmethod
def get_by_id_or_order(cls, trade_id, user):
try:
return cls.objects.get(id=trade_id, user=user)
except cls.DoesNotExist:
try:
return cls.objects.get(order_id=trade_id, user=user)
except cls.DoesNotExist:
return None
class Callback(models.Model): class Callback(models.Model):
hook = models.ForeignKey(Hook, on_delete=models.CASCADE) hook = models.ForeignKey(Hook, on_delete=models.CASCADE)

View File

@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block content %}
{% include 'partials/notify.html' %}
{% endblock %}

View File

@ -4,10 +4,11 @@
<th>account</th> <th>account</th>
<th>asset</th> <th>asset</th>
<th>price</th> <th>price</th>
<th>value in base</th> <th>units</th>
<th>value in quote</th> <th>quote</th>
<th>P/L</th> <th>P/L</th>
<th>side</th> <th>side</th>
<th>stored</th>
<th>actions</th> <th>actions</th>
</thead> </thead>
{% for item in items %} {% for item in items %}
@ -21,7 +22,18 @@
<td>{{ item.units }}</td> <td>{{ item.units }}</td>
<td>{{ item.value }}</td> <td>{{ item.value }}</td>
<td>{{ item.unrealized_pl }}</td> <td>{{ item.unrealized_pl }}</td>
<td>{{ item.side }}</td> <td>
{% if item.side == 'long' %}
<span class="icon has-text-success" data-tooltip="long">
<i class="fa-solid fa-up"></i>
</span>
{% elif item.side == 'short' %}
<span class="icon has-text-danger" data-tooltip="short">
<i class="fa-solid fa-down"></i>
</span>
{% endif %}
</td>
<td>{{ item.trades|length }}</td>
<td> <td>
<div class="buttons"> <div class="buttons">
<button <button

View File

@ -59,7 +59,8 @@
</span> </span>
</button> </button>
{% if type == 'page' %} {% if type == 'page' %}
<a href="#"><button <a href="{% url 'trade_action' type=type trade_id=item.id %}">
<button
class="button is-success"> class="button is-success">
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
@ -71,7 +72,7 @@
{% else %} {% else %}
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="#" hx-get="{% url 'trade_action' type=type trade_id=item.id %}"
hx-trigger="click" hx-trigger="click"
hx-target="#{{ type }}s-here" hx-target="#{{ type }}s-here"
hx-swap="innerHTML" hx-swap="innerHTML"

View File

@ -1,3 +1,53 @@
TRADE DETAILS {% load pretty %}
{{ items }} {% include 'partials/notify.html' %}
{{ status }}
<h1 class="title">Live information</h1>
<table class="table is-fullwidth is-hoverable">
<thead>
<th>attribute</th>
<th>value</th>
</thead>
<tbody>
{% for key, item in live_info.items %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
{{ item }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="title">Stored information</h1>
<table class="table is-fullwidth is-hoverable">
<thead>
<th>attribute</th>
<th>value</th>
</thead>
<tbody>
{% for key, item in db_info.items %}
{% if key == 'instruments' %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
<pre>{{ item|pretty }}</pre>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<th>{{ key }}</th>
<td>
{% if item is not None %}
{{ item }}
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View File

@ -1,5 +1,6 @@
{% include 'partials/notify.html' %} {% include 'partials/notify.html' %}
<h1 class="title">Live information</h1> <h1 class="title">Live information</h1>
{{ valid_trade_ids }}
<table class="table is-fullwidth is-hoverable"> <table class="table is-fullwidth is-hoverable">
<thead> <thead>
<th>attribute</th> <th>attribute</th>
@ -8,12 +9,31 @@
<tbody> <tbody>
{% for key, item in items.items %} {% for key, item in items.items %}
<tr> <tr>
{% if key == 'trade_ids' %}
<th>{{ key }}</th>
<td>
{% if item is not None %}
{% for trade_id in item %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'trade_action' type=type trade_id=trade_id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
class="button is-small {% if trade_id in valid_trade_ids %}is-primary{% else %}is-warning{% endif %}">
{{ trade_id }}
</button>
{% endfor %}
{% endif %}
</td>
{% else %}
<th>{{ key }}</th> <th>{{ key }}</th>
<td> <td>
{% if item is not None %} {% if item is not None %}
{{ item }} {{ item }}
{% endif %} {% endif %}
</td> </td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -227,7 +227,9 @@ class ObjectCreate(RestrictedViewMixin, ObjectNameMixin, CreateView):
context["submit_url"] = submit_url context["submit_url"] = submit_url
context["list_url"] = list_url context["list_url"] = list_url
context["type"] = type context["type"] = type
return self.render_to_response(context) response = self.render_to_response(context)
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.request = request self.request = request
@ -298,7 +300,9 @@ class ObjectUpdate(RestrictedViewMixin, ObjectNameMixin, UpdateView):
context["context_object_name_singular"] = self.context_object_name_singular context["context_object_name_singular"] = self.context_object_name_singular
context["submit_url"] = submit_url context["submit_url"] = submit_url
context["type"] = type context["type"] = type
return self.render_to_response(context) response = self.render_to_response(context)
# response["HX-Trigger"] = f"{self.context_object_name_singular}Event"
return response
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.request = request self.request = request

View File

@ -8,7 +8,7 @@ from rest_framework.parsers import FormParser
from two_factor.views.mixins import OTPRequiredMixin from two_factor.views.mixins import OTPRequiredMixin
from core.exchanges import GenericAPIError from core.exchanges import GenericAPIError
from core.models import Account from core.models import Account, Trade
from core.util import logs from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
@ -28,6 +28,32 @@ def get_positions(user, account_id=None):
return items return items
def annotate_positions(positions, user, return_order_ids=False):
"""
Annotate positions with trade information.
If return_order_ids is True, yield a list of order_ids instead of a list of trades.
:param positions: list of positions
:param user: user
:param return_order_ids: whether to return a generator of order_ids
:return: None or list of order_ids
:rtype: None or generator
"""
for item in positions:
try:
if "trade_ids" in item:
if return_order_ids:
for trade_id in Trade.objects.filter(
user=user, order_id__in=item["trade_ids"]
):
yield trade_id.order_id
else:
item["trades"] = Trade.objects.filter(
user=user, order_id__in=item["trade_ids"]
)
except Trade.DoesNotExist:
pass
class Positions(LoginRequiredMixin, OTPRequiredMixin, View): class Positions(LoginRequiredMixin, OTPRequiredMixin, View):
allowed_types = ["modal", "widget", "window", "page"] allowed_types = ["modal", "widget", "window", "page"]
window_content = "window-content/objects.html" window_content = "window-content/objects.html"
@ -41,6 +67,7 @@ class Positions(LoginRequiredMixin, OTPRequiredMixin, View):
template_name = f"wm/{type}.html" template_name = f"wm/{type}.html"
unique = str(uuid.uuid4())[:8] unique = str(uuid.uuid4())[:8]
items = get_positions(request.user, account_id) items = get_positions(request.user, account_id)
annotate_positions(items, request.user, return_order_ids=False)
if type == "page": if type == "page":
type = "modal" type = "modal"
context = { context = {
@ -66,12 +93,15 @@ class PositionAction(LoginRequiredMixin, OTPRequiredMixin, View):
Get live information for a trade. Get live information for a trade.
""" """
if type not in self.allowed_types: if type not in self.allowed_types:
return HttpResponseBadRequest return HttpResponseBadRequest()
template_name = f"wm/{type}.html" template_name = f"wm/{type}.html"
unique = str(uuid.uuid4())[:8] unique = str(uuid.uuid4())[:8]
account = Account.get_by_id(account_id, request.user) account = Account.get_by_id(account_id, request.user)
info = account.client.get_position_info(symbol) info = account.client.get_position_info(symbol)
valid_trade_ids = list(
annotate_positions([info], request.user, return_order_ids=True)
)
if type == "page": if type == "page":
type = "modal" type = "modal"
@ -81,6 +111,7 @@ class PositionAction(LoginRequiredMixin, OTPRequiredMixin, View):
"window_content": self.window_content, "window_content": self.window_content,
"type": type, "type": type,
"items": info, "items": info,
"valid_trade_ids": valid_trade_ids,
} }
return render(request, template_name, context) return render(request, template_name, context)

View File

@ -1,8 +1,12 @@
import uuid
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseBadRequest
from django.shortcuts import render from django.shortcuts import render
from django.views import View from django.views import View
from two_factor.views.mixins import OTPRequiredMixin from two_factor.views.mixins import OTPRequiredMixin
from core.exchanges import GenericAPIError
from core.forms import TradeForm from core.forms import TradeForm
from core.models import Trade from core.models import Trade
from core.util import logs from core.util import logs
@ -17,6 +21,44 @@ from core.views import (
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
class TradeAction(LoginRequiredMixin, OTPRequiredMixin, View):
allowed_types = ["modal", "widget", "window", "page"]
window_content = "window-content/trade.html"
def get(self, request, type, trade_id):
"""
Get live information for a trade.
"""
if type not in self.allowed_types:
return HttpResponseBadRequest()
template_name = f"wm/{type}.html"
unique = str(uuid.uuid4())[:8]
db_info = Trade.get_by_id_or_order(trade_id, request.user)
if db_info is None:
return HttpResponseBadRequest("Trade not found.")
if db_info.order_id is not None:
try:
live_info = db_info.account.client.get_trade(db_info.order_id)
except GenericAPIError as e:
live_info = {"error": e}
else:
live_info = {}
if type == "page":
type = "modal"
context = {
"title": f"Trade info ({type})",
"unique": unique,
"window_content": self.window_content,
"type": type,
"db_info": db_info.__dict__,
"live_info": live_info,
}
return render(request, template_name, context)
class TradeList(LoginRequiredMixin, OTPRequiredMixin, ObjectList): class TradeList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/trade-list.html" list_template = "partials/trade-list.html"
model = Trade model = Trade