Skip to content

Commit e3912c1

Browse files
Mouses007claude
andcommitted
Add Trading Positions desklet
Desktop widget for Crypto Trading Journal (github.com/Mouses007/Crypto-Trading-Journal). Shows open Bitunix futures positions with real-time PnL from live Bitunix API. Includes i18n support with German translation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 541c7f3 commit e3912c1

9 files changed

Lines changed: 840 additions & 0 deletions

File tree

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
// Trading Positions Desklet
2+
// Shows open positions from Crypto Trading Journal
3+
// Requires: Cinnamon 5.0+
4+
5+
const Desklet = imports.ui.desklet;
6+
const St = imports.gi.St;
7+
const GLib = imports.gi.GLib;
8+
const Gio = imports.gi.Gio;
9+
const Mainloop = imports.mainloop;
10+
const Settings = imports.ui.settings;
11+
const Gettext = imports.gettext;
12+
13+
// Soup version detection: Cinnamon < 5.8 uses Soup 2, newer uses Soup 3
14+
let Soup;
15+
try {
16+
imports.gi.versions.Soup = '3.0';
17+
Soup = imports.gi.Soup;
18+
var SOUP_VERSION = 3;
19+
} catch(e) {
20+
Soup = imports.gi.Soup;
21+
var SOUP_VERSION = 2;
22+
}
23+
24+
const UUID = "trading-positions@trading-journal";
25+
const APP_VERSION = "2.7.1";
26+
const APP_NAME = "Crypto Trading Journal";
27+
28+
Gettext.bindtextdomain(UUID, GLib.get_home_dir() + "/.local/share/locale");
29+
30+
function _(str) {
31+
return Gettext.dgettext(UUID, str);
32+
}
33+
34+
class TradingPositionsDesklet extends Desklet.Desklet {
35+
36+
constructor(metadata, desklet_id) {
37+
super(metadata, desklet_id);
38+
this._metadata = metadata;
39+
40+
// Settings
41+
this._settings = new Settings.DeskletSettings(this, UUID, desklet_id);
42+
this._settings.bind("server-host", "serverHost", this._onSettingChanged.bind(this));
43+
this._settings.bind("server-port", "serverPort", this._onSettingChanged.bind(this));
44+
this._settings.bind("refresh-interval", "refreshInterval", this._onSettingChanged.bind(this));
45+
this._settings.bind("show-leverage", "showLeverage", this._onSettingChanged.bind(this));
46+
this._settings.bind("show-mark-price", "showMarkPrice", this._onSettingChanged.bind(this));
47+
this._settings.bind("show-github-link", "showGithubLink", this._onFooterChanged.bind(this));
48+
this._settings.bind("font-size", "fontSize", this._onStyleChanged.bind(this));
49+
this._settings.bind("font-size-ui", "fontSizeUi", this._onStyleChanged.bind(this));
50+
this._settings.bind("bg-opacity", "bgOpacity", this._onStyleChanged.bind(this));
51+
this._settings.bind("min-width", "minWidth", this._onStyleChanged.bind(this));
52+
53+
this._cookieAcquired = false;
54+
this._timeoutId = null;
55+
56+
this._initSoup();
57+
this._buildUI();
58+
this._applyStyle();
59+
this._startPolling();
60+
}
61+
62+
get _baseUrl() {
63+
let host = (this.serverHost || 'localhost').trim();
64+
return `http://${host}:${this.serverPort}`;
65+
}
66+
67+
// ------------------------------------------------------------------ Style
68+
69+
_applyStyle() {
70+
let opacity = Math.max(0, Math.min(100, this.bgOpacity ?? 88)) / 100;
71+
let fs = this.fontSize ?? 12;
72+
let fsUi = this.fontSizeUi ?? 11;
73+
let mw = this.minWidth ?? 400;
74+
this._outer.set_style(
75+
`background-color: rgba(15,20,28,${opacity.toFixed(2)});` +
76+
`min-width: ${mw}px;`
77+
);
78+
// Scale column widths based on font size (base: fs=12)
79+
let scale = fs / 12;
80+
this._colWidths = {
81+
symbol: Math.round(90 * scale),
82+
side: Math.round(55 * scale),
83+
leverage: Math.round(36 * scale),
84+
price: Math.round(78 * scale),
85+
pnl: Math.round(95 * scale),
86+
};
87+
// Positions
88+
this._posFs = fs;
89+
// UI elements (title, footer, total, header)
90+
this._headerFs = fs;
91+
this._titleStyle = `font-size: ${fsUi + 3}px;`;
92+
this._subtitleStyle = `font-size: ${fsUi}px;`;
93+
this._totalStyle = `font-size: ${fsUi}px;`;
94+
this._footerStyle = `font-size: ${Math.max(8, fsUi - 1)}px;`;
95+
}
96+
97+
_onStyleChanged() {
98+
this._applyStyle();
99+
if (this._titleLabel) this._titleLabel.set_style(this._titleStyle);
100+
if (this._subtitleLabel) this._subtitleLabel.set_style(this._subtitleStyle);
101+
if (this._footerTime) this._footerTime.set_style(this._footerStyle);
102+
if (this._footerRight) this._footerRight.set_style(this._footerStyle);
103+
this._refresh();
104+
}
105+
106+
// ------------------------------------------------------------------ Soup
107+
108+
_initSoup() {
109+
if (SOUP_VERSION === 3) {
110+
this._session = new Soup.Session();
111+
this._session.add_feature(new Soup.CookieJar());
112+
} else {
113+
this._session = new Soup.SessionAsync();
114+
this._session.add_feature(new Soup.CookieJar());
115+
}
116+
}
117+
118+
_soupGet(url, callback) {
119+
if (SOUP_VERSION === 3) {
120+
let msg = Soup.Message.new('GET', url);
121+
this._session.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, null, (session, result) => {
122+
try {
123+
let bytes = session.send_and_read_finish(result);
124+
let status = msg.get_status();
125+
let body = bytes ? new TextDecoder().decode(bytes.get_data()) : '';
126+
callback(status, body);
127+
} catch(e) {
128+
callback(0, '');
129+
}
130+
});
131+
} else {
132+
let msg = Soup.Message.new('GET', url);
133+
this._session.queue_message(msg, (session, response) => {
134+
let status = response.status_code;
135+
let body = response.response_body ? response.response_body.data : '';
136+
callback(status, body);
137+
});
138+
}
139+
}
140+
141+
// ------------------------------------------------------------------ UI
142+
143+
_buildUI() {
144+
let outer = new St.BoxLayout({ vertical: true, style_class: 'trading-desklet' });
145+
this._outer = outer;
146+
this.setContent(outer);
147+
148+
// --- Logo Header ---
149+
let headerRow = new St.BoxLayout({ style_class: 'desklet-header-row' });
150+
151+
// Logo
152+
let iconPath = this._metadata.path + '/icon.png';
153+
try {
154+
let gicon = Gio.icon_new_for_string(iconPath);
155+
let logo = new St.Icon({ gicon: gicon, icon_size: 42, style_class: 'desklet-logo' });
156+
headerRow.add_child(logo);
157+
} catch(_) {}
158+
159+
// Title
160+
let titleBox = new St.BoxLayout({ vertical: true, style_class: 'desklet-title-box' });
161+
this._titleLabel = new St.Label({ text: APP_NAME, style_class: 'desklet-title' });
162+
this._subtitleLabel = new St.Label({ text: _("Open Bitunix Futures"), style_class: 'desklet-subtitle' });
163+
titleBox.add_child(this._titleLabel);
164+
titleBox.add_child(this._subtitleLabel);
165+
headerRow.add_child(titleBox);
166+
outer.add_child(headerRow);
167+
168+
// Separator
169+
outer.add_child(new St.Label({ text: '\u2500'.repeat(44), style_class: 'separator' }));
170+
171+
// --- Content area (positions / status) ---
172+
this._content = new St.BoxLayout({ vertical: true, style_class: 'desklet-content' });
173+
outer.add_child(this._content);
174+
175+
// --- Footer ---
176+
outer.add_child(new St.Label({ text: '\u2500'.repeat(44), style_class: 'separator' }));
177+
let footer = new St.BoxLayout({ style_class: 'desklet-footer' });
178+
this._footerTime = new St.Label({ text: '', style_class: 'footer-time' });
179+
this._footerRight = new St.Label({ text: '', style_class: 'footer-right' });
180+
this._updateFooterRight();
181+
footer.add_child(this._footerTime);
182+
let spacer = new St.Widget({ x_expand: true });
183+
footer.add_child(spacer);
184+
footer.add_child(this._footerRight);
185+
outer.add_child(footer);
186+
187+
this._showStatus(_("Connecting..."));
188+
}
189+
190+
// ------------------------------------------------------------------ Polling
191+
192+
_startPolling() {
193+
this._stopPolling();
194+
this._refresh();
195+
this._timeoutId = Mainloop.timeout_add_seconds(this.refreshInterval, () => {
196+
this._refresh();
197+
return GLib.SOURCE_CONTINUE;
198+
});
199+
}
200+
201+
_stopPolling() {
202+
if (this._timeoutId) {
203+
Mainloop.source_remove(this._timeoutId);
204+
this._timeoutId = null;
205+
}
206+
}
207+
208+
_refresh() {
209+
if (this._cookieAcquired) {
210+
this._fetchPositions();
211+
} else {
212+
this._acquireCookie();
213+
}
214+
}
215+
216+
_acquireCookie() {
217+
this._showStatus(_("Connecting to") + " " + this.serverHost + "...");
218+
this._soupGet(this._baseUrl + '/', (status, _body) => {
219+
if (status > 0 && status < 500) {
220+
this._cookieAcquired = true;
221+
this._fetchPositions();
222+
} else {
223+
this._showStatus(_("Server unreachable") + "\n" + this.serverHost + ":" + this.serverPort);
224+
}
225+
});
226+
}
227+
228+
_fetchPositions() {
229+
this._soupGet(this._baseUrl + '/api/bitunix/open-positions', (status, body) => {
230+
if (status === 401) {
231+
this._cookieAcquired = false;
232+
this._acquireCookie();
233+
return;
234+
}
235+
if (status !== 200) {
236+
this._showStatus(_("Server unreachable") + "\n" + this.serverHost + ":" + this.serverPort);
237+
return;
238+
}
239+
try {
240+
let result = JSON.parse(body);
241+
let positions = result.positions || [];
242+
this._renderPositions(positions);
243+
this._updateFooterTime();
244+
} catch(e) {
245+
this._showStatus(_("Parse error:") + "\n" + e.message);
246+
}
247+
});
248+
}
249+
250+
_updateFooterTime() {
251+
let now = new Date();
252+
let hh = String(now.getHours()).padStart(2, '0');
253+
let mm = String(now.getMinutes()).padStart(2, '0');
254+
let ss = String(now.getSeconds()).padStart(2, '0');
255+
if (this._footerTime) {
256+
this._footerTime.set_text(_("Updated:") + ` ${hh}:${mm}:${ss}`);
257+
}
258+
}
259+
260+
_updateFooterRight() {
261+
if (!this._footerRight) return;
262+
let text = `v${APP_VERSION}`;
263+
if (this.showGithubLink !== false) {
264+
text += ` \u2022 github.com/Mouses007/Crypto-Trading-Journal`;
265+
}
266+
this._footerRight.set_text(text);
267+
}
268+
269+
_onFooterChanged() {
270+
this._updateFooterRight();
271+
}
272+
273+
// ------------------------------------------------------------------ Render
274+
275+
_renderPositions(positions) {
276+
this._content.destroy_all_children();
277+
278+
if (positions.length === 0) {
279+
this._content.add_child(
280+
new St.Label({ text: _("No open positions"), style_class: 'no-positions-label' })
281+
);
282+
return;
283+
}
284+
285+
// Total PnL
286+
let totalPnl = positions.reduce((sum, p) => sum + (parseFloat(p.unrealizedPNL) || 0), 0);
287+
let totalClass = totalPnl >= 0 ? 'pnl-profit' : 'pnl-loss';
288+
let totalSign = totalPnl >= 0 ? '+' : '';
289+
let totalRow = new St.BoxLayout({ style_class: 'total-row' });
290+
let posWord = positions.length === 1 ? _("position") : _("positions");
291+
let totalLbl = new St.Label({
292+
text: `${_("Total:")} ${totalSign}${totalPnl.toFixed(2)} USDT (${positions.length} ${posWord})`,
293+
style_class: 'total-label ' + totalClass
294+
});
295+
if (this._totalStyle) totalLbl.set_style(this._totalStyle);
296+
totalRow.add_child(totalLbl);
297+
this._content.add_child(totalRow);
298+
this._content.add_child(new St.Label({ text: '\u2500'.repeat(44), style_class: 'separator-thin' }));
299+
300+
// Header row — same column widths as position rows
301+
let headerCells = [
302+
{ text: _("Symbol"), width: this._colWidths.symbol },
303+
{ text: _("Side"), width: this._colWidths.side },
304+
];
305+
if (this.showLeverage) headerCells.push({ text: _("Lvg"), width: this._colWidths.leverage });
306+
headerCells.push({ text: _("Entry"), width: this._colWidths.price });
307+
if (this.showMarkPrice) headerCells.push({ text: _("Mark"), width: this._colWidths.price });
308+
headerCells.push({ text: _("unr. PnL"), width: this._colWidths.pnl });
309+
this._content.add_child(this._makeHeaderRow(headerCells));
310+
311+
for (let pos of positions) {
312+
this._content.add_child(this._makePositionRow(pos));
313+
}
314+
}
315+
316+
_makePositionRow(pos) {
317+
let pnl = parseFloat(pos.unrealizedPNL) || 0;
318+
let isProfit = pnl >= 0;
319+
let pnlClass = isProfit ? 'pnl-profit' : 'pnl-loss';
320+
let pnlText = (isProfit ? '+' : '') + pnl.toFixed(2) + ' USDT';
321+
let sideLower = (pos.side || '').toLowerCase();
322+
let sideClass = (sideLower === 'long' || sideLower === 'buy') ? 'side-long' : 'side-short';
323+
324+
let markPrice = pos.markPrice;
325+
if (!markPrice && pos.bitunixData) {
326+
try {
327+
let bd = typeof pos.bitunixData === 'string' ? JSON.parse(pos.bitunixData) : pos.bitunixData;
328+
markPrice = bd.markPrice || bd.liqPrice || null;
329+
} catch(_) {}
330+
}
331+
332+
let fmt = (v) => {
333+
if (!v) return '-';
334+
let n = parseFloat(v);
335+
if (isNaN(n)) return '-';
336+
return n >= 1000 ? n.toLocaleString('en-US', { maximumFractionDigits: 2 })
337+
: n >= 1 ? n.toFixed(4)
338+
: n.toFixed(6);
339+
};
340+
341+
let cw = this._colWidths;
342+
let cells = [
343+
{ text: pos.symbol || '-', cls: 'symbol-cell', width: cw.symbol },
344+
{ text: pos.side || '-', cls: 'side-cell ' + sideClass, width: cw.side },
345+
];
346+
if (this.showLeverage) cells.push({ text: (pos.leverage ? pos.leverage + 'x' : '-'), cls: 'leverage-cell', width: cw.leverage });
347+
cells.push({ text: fmt(pos.entryPrice), cls: 'price-cell', width: cw.price });
348+
if (this.showMarkPrice) cells.push({ text: fmt(markPrice), cls: 'price-cell', width: cw.price });
349+
cells.push({ text: pnlText, cls: 'pnl-cell ' + pnlClass, width: cw.pnl });
350+
351+
let fs = this._posFs || 12;
352+
let row = new St.BoxLayout({ style_class: 'position-row' });
353+
for (let c of cells) {
354+
let lbl = new St.Label({ text: c.text, style_class: c.cls });
355+
lbl.set_style(`font-size: ${fs}px; min-width: ${c.width}px;`);
356+
row.add_child(lbl);
357+
}
358+
return row;
359+
}
360+
361+
_makeHeaderRow(cells) {
362+
let fs = this._headerFs || 12;
363+
let row = new St.BoxLayout({ style_class: 'header-row' });
364+
for (let c of cells) {
365+
let lbl = new St.Label({ text: c.text, style_class: 'header-cell' });
366+
lbl.set_style(`font-size: ${fs}px; min-width: ${c.width}px;`);
367+
row.add_child(lbl);
368+
}
369+
return row;
370+
}
371+
372+
_showStatus(text) {
373+
this._content.destroy_all_children();
374+
this._content.add_child(new St.Label({ text, style_class: 'status-label' }));
375+
}
376+
377+
// ------------------------------------------------------------------ Lifecycle
378+
379+
_onSettingChanged() {
380+
this._cookieAcquired = false;
381+
this._startPolling();
382+
}
383+
384+
on_desklet_removed() {
385+
this._stopPolling();
386+
}
387+
}
388+
389+
function main(metadata, desklet_id) {
390+
return new TradingPositionsDesklet(metadata, desklet_id);
391+
}
1.66 MB
Loading

0 commit comments

Comments
 (0)