Trigger GUI for Vigilant Crypto Snatch
At the beginning, the status screen of the Vigilant Crypto Snatch program looks rather bleak. It somehow has information on it, but it isn't really nice to look at. There is no real hierarchy of information, lots of empty space. I just don't like it.
What I would like to see is more information about all the individual triggers that are active, not just there mere names. Also the database cleaning trigger is an implementation detail, the user should not be distracted with that. In this article I will show how I have improved that screen.
Refactoring cooldowns
Looking at the core library, we find the following interface definition for a trigger:
class Trigger(object): def get_name(self) -> str: raise NotImplementedError() # pragma: no cover def fire(self, now: datetime.datetime) -> None: raise NotImplementedError() # pragma: no cover def has_cooled_off(self, now: datetime.datetime) -> bool: raise NotImplementedError() # pragma: no cover def is_triggered(self, now: datetime.datetime) -> bool: raise NotImplementedError() # pragma: no cover
It has two separate methods which decide whether the trigger has to be executed fire()
, namely has_cooled_off()
and is_triggered()
. At some point this design had made sense. I thought that I would create an inheritance hierarchy with triggers, and would create one has_cooled_off()
with a timeout and then only override the is_triggered()
in the base classes. But, as, usual with inheritance, it turned out to be a bad idea. Instead I settled on delegates. There is now the BuyTrigger
class, which implements the Trigger
interface. And its is_triggered()
method now simply asks a bunch of delegates whether it is time to execute:
def is_triggered(self, now: datetime.datetime) -> bool: return all( triggered_delegate.is_triggered(now) for triggered_delegate in self.triggered_delegates )
Separate of this, there is the has_cooled_down()
, which has some additional criteria on its own:
def has_cooled_off(self, now: datetime.datetime) -> bool: if self.failure_timeout.has_timeout(now): return False if self.start is not None and now < self.start: return False then = now - datetime.timedelta(minutes=self.cooldown_minutes) return not self.datastore.was_triggered_since( self.get_name(), self.asset_pair, then )
This does three things:
- It checks whether it has a failure timeout. This might be set if there have been insufficient funds.
- It checks whether the user has specified a start date and time, and whether we are already past that.
- It checks whether the last trigger execution was the given cooloff ago.
Doing three things in one method is bad on its own. But it gets worse. Somewhere else in the code we have the following, which checks whether triggers need firing, and then fires it:
if trigger.has_cooled_off(now) and trigger.is_triggered(now): trigger.fire(now)
Now that I explain it this way, it doesn't make so much sense. The cooloff predicates are just another set of reasons why a trigger might not need to be fired, so I could just refactor the cooloff, the start and the failure timeout into just another three TriggeredDelegate
objects implementing this interface:
class TriggeredDelegate(object): def is_triggered(self, now: datetime.datetime) -> bool: raise NotImplementedError() # pragma: no cover
Once I have done that, I could display the user a table. Each row is a trigger, and there is a column for the name and then each triggered delegate such that one could readily see the detailed status of each trigger. This would provide a lot of information. The user could see that a given trigger has cooled down, but the price has not sufficiently dropped. Or that the Fear & Greed index is sufficiently low, but it has not cooled down. While I am at it, I could also make it another condition that there is sufficient balance on the market, to avoid making it an error.
The triggered delegates are now stored in a dictionary. The core library doesn't do anything with the labels, but they are used in the GUI as column headers. With the appropriate table model in place, this is what it looks like:
One problem is that this is sluggish. Whenever one goes to the status screen, it has to query the balance for each trigger. This is bad, the HTTP should not happen in the main UI thread. I could add a bit of caching, but then it would cause problems when a buy action is executed and the next trigger is tried out directly after. Cache invalidation directly becomes problematic. It becomes decent when I add caching, but invalidate it before a buy order and before a withdrawal. This way the next trigger evaluation will get a fresh result. It still happens in the main thread, so this should be improved.
Also the table needs to be refreshed when there are changed made by the watching thread. If a trigger gets executed, its cooldown will be activated and the table view needs to be told to refresh. The watching thread need to update the UI. And ideally it would do all the necessary computations and hand the full table data to the UI.
I have introduced a “dumb table model”, which gets the data fed. This way I can decouple computation from display and make the UI really snappy. The computation is done in the worker thread, whereas the UI drawing is done in the main thread. Once the worker is done with the computation, it will notify all attached tables that new data is available. With this table model I have created tables for the balance and prices. After a few more simplifications, the UI now looks like this:
I have added an icon for the program. Also new is a system tray icon which allows me to send notifications via the logging mechanism.
Improving the configuration screen layout
While I was at it, I changed the configuration screen back from the QToolBox
to a tab panel. Before it looked like this:
And now there are two layers of tabs. I feared that it would be too hard to parse, but I think that it actually is easier to use than before.
Conclusion
Working with the Qt table models makes a lot of sense to me now. When I used Qt in 2015, where I lacked the experience with software architecture, it seemed utterly complicated. I sensed that there was a point in doing it like that, but I didn't feel why that makes so much sense. Now I do, and I like Qt even more than before.
The changes to the program were a bit challenging because I had to find a way to put all the latency inducing stuff into an extra thread. But now it seems to work.
Also I finally got a program with a system tray icon and native desktop notifications, which is something that I wanted to have for a very long time, but didn't know how to do it.
I feel that the latest iteration of the GUI is somewhat mature. A professional GUI designer likely has many ideas for improvement, but I think that it doesn't look horrible any more, at least.