-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
269 lines (228 loc) · 11.6 KB
/
Copy pathmain.py
File metadata and controls
269 lines (228 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import logging
import objc
import requests
import threading
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from AppKit import (NSApplication, NSStatusBar, NSVariableStatusItemLength,
NSMenu, NSMenuItem, NSTimer, NSObject, NSApp,
NSApplicationActivationPolicyProhibited, NSFont,
NSAttributedString, NSSound)
from PyObjCTools import AppHelper
from premium_fetch import fetch_premium
from resources import load_sessions, menu_lines_for_cpi_section
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Constants
PREMIUM_ALERT_THRESHOLD_PCT = 0.1
UI_UPDATE_INTERVAL_SEC = 60.0
PROGRESS_BAR_WIDTH = 10
class CryptoMasterSessionsApp(NSObject):
def init(self):
self = objc.super(CryptoMasterSessionsApp, self).init()
if self is None:
return None
self.status_item = NSStatusBar.systemStatusBar().statusItemWithLength_(NSVariableStatusItemLength)
self.last_active_count = 0
self._http_session: requests.Session = requests.Session()
self._fetch_generation = 0
self.sessions = load_sessions()
return self
def applicationDidFinishLaunching_(self, notification):
NSApp.setActivationPolicy_(NSApplicationActivationPolicyProhibited)
self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
UI_UPDATE_INTERVAL_SEC, self, "updateUI:", None, True
)
self.updateUI_(None)
def _fetch_premium_in_background(self, generation: int) -> None:
"""Fetch premium data in background; refresh UI only if generation is still current."""
try:
btc = fetch_premium("BTC", self._http_session)
eth = fetch_premium("ETH", self._http_session)
except Exception as e:
logger.warning("Premium fetch failed: %s", e)
btc, eth = (None, 0.0, 0.0), (None, 0.0, 0.0)
AppHelper.callAfter(self._apply_premium_if_current, generation, btc, eth)
@objc.python_method
def _apply_premium_if_current(self, generation: int, btc, eth) -> None:
if generation != self._fetch_generation:
logger.debug(
"Stale premium fetch ignored (gen %s, current %s)",
generation,
self._fetch_generation,
)
return
self._applyPremiumAndRefreshMenu_(btc, eth)
@objc.python_method
def get_progress_bar(self, current: float, start: float, end: float) -> str:
width = PROGRESS_BAR_WIDTH
if end < start: # Midnight wrap logic (CME)
total = (24 - start) + end
prog_val = (current - start) if current >= start else (24 - start + current)
else:
total = end - start
prog_val = current - start
if total <= 0:
return ""
progress = int((prog_val / total) * width)
progress = max(0, min(width, progress))
return f"[{'▬' * progress}{' ' * (width - progress)}]"
@objc.python_method
def get_time_diff_minutes(self, now, target_hour, is_weekend_override=False):
current_total = now.hour * 60 + now.minute
target_total = int(target_hour * 60)
if is_weekend_override:
days_to_mon = 7 - now.weekday()
diff = (1440 - current_total) + ((days_to_mon - 1) * 1440) + target_total
else:
diff = target_total - current_total
if diff < 0:
diff += 1440
return diff
@objc.python_method
def create_menu_item(self, title, is_alert=False, is_active=False):
action = "dummyAction:" if (is_alert or is_active) else None
item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(title, action, "")
if action:
item.setTarget_(self)
item.setEnabled_(True)
font_name = "Menlo-Bold" if (is_alert or is_active) else "Menlo"
font = NSFont.fontWithName_size_(font_name, 14.0)
attr_title = NSAttributedString.alloc().initWithString_attributes_(title, {"NSFont": font})
item.setAttributedTitle_(attr_title)
return item
@objc.python_method
def _format_price(self, price):
"""Format price for display (e.g. 97234.5 -> '97,234.50')."""
if price is None:
return "—"
return f"{price:,.2f}"
@objc.python_method
def _buildAndSetMenuWithPremium_(self, premium_btc=None, premium_eth=None):
"""Build status menu and set it. premium_btc/premium_eth are (price, amount, pct) or None (no network)."""
active_codes = []
upcoming = []
new_menu = NSMenu.alloc().init()
# First two lines: current BTC and ETH prices
price_btc = premium_btc[0] if premium_btc is not None else None
price_eth = premium_eth[0] if premium_eth is not None else None
new_menu.addItem_(self.create_menu_item(f"BTC ${self._format_price(price_btc)}", is_active=True))
new_menu.addItem_(self.create_menu_item(f"ETH ${self._format_price(price_eth)}", is_active=True))
now_local = datetime.now()
# Gap Risk: visible Sat, Sun, and Mon until 10:00 local time
show_gap_risk = (now_local.weekday() >= 5) or (now_local.weekday() == 0 and now_local.hour < 10)
# Block before exchanges: trading hours section
new_menu.addItem_(NSMenuItem.separatorItem())
new_menu.addItem_(self.create_menu_item("🕐 Exchange trading hours", is_active=False))
new_menu.addItem_(NSMenuItem.separatorItem())
for s in self.sessions:
try:
tz = ZoneInfo(s["tz"])
now = datetime.now(tz)
curr_f = now.hour + now.minute / 60.0
if s.get("venue") == "cme":
is_cme_closed = (now.weekday() == 4 and now.hour >= 16) or \
(now.weekday() == 5) or \
(now.weekday() == 6 and now.hour < 17)
is_active = not is_cme_closed and now.hour != 16
diff = self.get_time_diff_minutes(now, 16 if is_active else 17)
prog = self.get_progress_bar(curr_f, 17, 16) if is_active else ""
if is_active:
status_lbl = "ACT"
elif is_cme_closed:
status_lbl = "WKND"
else:
status_lbl = "OPEN"
else:
is_wknd = now.weekday() >= 5
is_active = (s["open"] <= curr_f < s["close"]) and not is_wknd
diff = self.get_time_diff_minutes(now, s['close'] if is_active else s['open'], is_wknd)
prog = self.get_progress_bar(curr_f, s['open'], s['close']) if is_active else ""
if is_active:
status_lbl = "ACT"
elif is_wknd:
status_lbl = "WKND"
elif curr_f < s["open"]:
status_lbl = "OPEN"
else:
status_lbl = "CLSD"
status_str = f"{status_lbl} {prog}" if is_active else f"{status_lbl} in {diff//60:02d}h {diff%60:02d}m"
if is_active:
active_codes.append(f"{s['icon']} {s['id']}")
else:
upcoming.append((diff, s['id']))
menu_title = f"{s['mkt']} {s['icon']} {now.strftime('%H:%M')} » {status_str}"
new_menu.addItem_(self.create_menu_item(menu_title, is_active=is_active))
except (KeyError, ValueError) as e:
logger.warning("Skipping invalid session row: %s", e)
if show_gap_risk:
new_menu.addItem_(NSMenuItem.separatorItem())
new_menu.addItem_(self.create_menu_item("⚠️ !!! CME GAP RISK ACTIVE !!! ⚠️", is_alert=True))
# Block after exchanges: CPI section (dates from data/cpi_dates.json)
new_menu.addItem_(NSMenuItem.separatorItem())
new_menu.addItem_(self.create_menu_item("📊 US CPI (ET calendar)", is_active=False))
new_menu.addItem_(NSMenuItem.separatorItem())
for line in menu_lines_for_cpi_section():
new_menu.addItem_(self.create_menu_item(line, is_active=False))
if len(active_codes) > self.last_active_count:
NSSound.soundNamed_("Glass").play()
self.last_active_count = len(active_codes)
# Use passed-in premium data (from background fetch) or skip row
premium_alert_played = False
if premium_btc is not None:
_, premium_amount_btc, premium_percentage_btc = premium_btc
if abs(premium_percentage_btc) >= PREMIUM_ALERT_THRESHOLD_PCT and not premium_alert_played:
NSSound.soundNamed_("Basso").play()
premium_alert_played = True
if premium_amount_btc != 0 or premium_percentage_btc != 0:
color = "🟢" if premium_amount_btc > 0 else "🔴"
premium_str = f"BTC Prem: {color} {premium_amount_btc:.4f} ({premium_percentage_btc:.2f}%)"
new_menu.addItem_(self.create_menu_item(premium_str, is_active=True))
if premium_eth is not None:
_, premium_amount_eth, premium_percentage_eth = premium_eth
if abs(premium_percentage_eth) >= PREMIUM_ALERT_THRESHOLD_PCT and not premium_alert_played:
NSSound.soundNamed_("Basso").play()
premium_alert_played = True
if premium_amount_eth != 0 or premium_percentage_eth != 0:
color = "🟢" if premium_amount_eth > 0 else "🔴"
premium_str = f"ETH Prem: {color} {premium_amount_eth:.4f} ({premium_percentage_eth:.2f}%)"
new_menu.addItem_(self.create_menu_item(premium_str, is_active=True))
new_menu.addItem_(NSMenuItem.separatorItem())
quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Quit App", "terminateApp:", "q")
quit_item.setTarget_(self)
new_menu.addItem_(quit_item)
self.status_item.setMenu_(new_menu)
if active_codes:
prefix = "🔥 " if len(active_codes) >= 3 else ""
self.status_item.button().setTitle_(f"{prefix}{' '.join(active_codes)}")
else:
if upcoming:
upcoming.sort()
next_m, next_id = upcoming[0]
self.status_item.button().setTitle_(f"💤 {next_id}: {next_m//60}h {next_m%60}m")
else:
self.status_item.button().setTitle_("💤 No sessions")
@objc.python_method
def _applyPremiumAndRefreshMenu_(self, premium_btc, premium_eth):
"""Called on main thread after background fetch; refreshes menu with premium data."""
self._buildAndSetMenuWithPremium_(premium_btc, premium_eth)
def updateUI_(self, _):
# Build menu immediately without network (sessions only); UI stays responsive
self._buildAndSetMenuWithPremium_(None, None)
self._fetch_generation += 1
gen = self._fetch_generation
threading.Thread(
target=lambda: self._fetch_premium_in_background(gen),
daemon=True,
).start()
@objc.typedSelector(b"v@:@")
def dummyAction_(self, _): pass
@objc.typedSelector(b"v@:@")
def terminateApp_(self, _: Any) -> None:
NSApp.terminate_(self)
if __name__ == "__main__":
app = NSApplication.sharedApplication()
delegate = CryptoMasterSessionsApp.alloc().init()
app.setDelegate_(delegate)
AppHelper.runEventLoop()