|
| 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 | +} |
0 commit comments