terminex 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terminex/__init__.py +1 -0
- terminex/__main__.py +10 -0
- terminex/alert_engine.py +69 -0
- terminex/alerts.py +117 -0
- terminex/alerts_ui.py +129 -0
- terminex/app.py +842 -0
- terminex/config.py +67 -0
- terminex/converter.py +197 -0
- terminex/currencies.py +35 -0
- terminex/display.py +192 -0
- terminex/help.py +89 -0
- terminex/keyboard.py +134 -0
- terminex/providers/__init__.py +1 -0
- terminex/providers/base.py +35 -0
- terminex/providers/commodities_stooq.py +133 -0
- terminex/providers/crypto_coincap.py +101 -0
- terminex/providers/crypto_coingecko.py +103 -0
- terminex/providers/fx_erapi.py +74 -0
- terminex/providers/watchlist_agg.py +81 -0
- terminex/quote.py +33 -0
- terminex/series.py +75 -0
- terminex/sparkline.py +50 -0
- terminex/statusbar.py +75 -0
- terminex/store.py +87 -0
- terminex/theme.py +48 -0
- terminex/watchlist.py +85 -0
- terminex-0.4.0.dist-info/METADATA +209 -0
- terminex-0.4.0.dist-info/RECORD +32 -0
- terminex-0.4.0.dist-info/WHEEL +5 -0
- terminex-0.4.0.dist-info/entry_points.txt +2 -0
- terminex-0.4.0.dist-info/licenses/LICENSE +21 -0
- terminex-0.4.0.dist-info/top_level.txt +1 -0
terminex/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""terminex — live FX rates in the terminal."""
|
terminex/__main__.py
ADDED
terminex/alert_engine.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Alert evaluation against live snapshots.
|
|
2
|
+
|
|
3
|
+
Holds in-memory ``last_price`` per (asset_class, symbol) to detect
|
|
4
|
+
threshold *crossings* rather than firing every tick while the
|
|
5
|
+
condition stays true.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from . import alerts as alerts_dao
|
|
14
|
+
from .alerts import Alert, _row_to_alert
|
|
15
|
+
from .quote import Snapshot
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FireEvent:
|
|
20
|
+
alert: Alert
|
|
21
|
+
price: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AlertEngine:
|
|
25
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
26
|
+
self.conn = conn
|
|
27
|
+
self._last_price: dict[tuple[str, str], float] = {}
|
|
28
|
+
|
|
29
|
+
def evaluate(self, snapshot: Snapshot, asset_class: str) -> list[FireEvent]:
|
|
30
|
+
quote_by_sym = {q.symbol: q.price for q in snapshot.quotes}
|
|
31
|
+
# Only load alerts for this asset_class
|
|
32
|
+
rows = self.conn.execute(
|
|
33
|
+
"""
|
|
34
|
+
SELECT * FROM alerts
|
|
35
|
+
WHERE active = 1 AND asset_class = ?
|
|
36
|
+
""",
|
|
37
|
+
(asset_class,),
|
|
38
|
+
).fetchall()
|
|
39
|
+
fires: list[FireEvent] = []
|
|
40
|
+
for row in rows:
|
|
41
|
+
alert = _row_to_alert(row)
|
|
42
|
+
price = quote_by_sym.get(alert.symbol)
|
|
43
|
+
if price is None:
|
|
44
|
+
continue
|
|
45
|
+
key = (asset_class, alert.symbol)
|
|
46
|
+
prev = self._last_price.get(key)
|
|
47
|
+
triggered = _is_triggered(alert, price, prev)
|
|
48
|
+
if triggered:
|
|
49
|
+
alerts_dao.record_fire(self.conn, alert.id, price)
|
|
50
|
+
if not alert.recurring:
|
|
51
|
+
alerts_dao.deactivate(self.conn, alert.id)
|
|
52
|
+
fires.append(FireEvent(alert=alert, price=price))
|
|
53
|
+
# update last-seen prices for every symbol in this snapshot
|
|
54
|
+
for sym, price in quote_by_sym.items():
|
|
55
|
+
self._last_price[(asset_class, sym)] = price
|
|
56
|
+
return fires
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_triggered(alert: Alert, price: float, prev: float | None) -> bool:
|
|
60
|
+
if alert.op == ">":
|
|
61
|
+
if price <= alert.threshold:
|
|
62
|
+
return False
|
|
63
|
+
# first observation OR prev did not satisfy the condition
|
|
64
|
+
return prev is None or prev <= alert.threshold
|
|
65
|
+
if alert.op == "<":
|
|
66
|
+
if price >= alert.threshold:
|
|
67
|
+
return False
|
|
68
|
+
return prev is None or prev >= alert.threshold
|
|
69
|
+
return False
|
terminex/alerts.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Alert dataclass and DAO.
|
|
2
|
+
|
|
3
|
+
An alert fires when ``price`` crosses ``threshold`` in the ``op``
|
|
4
|
+
direction. Non-recurring alerts auto-deactivate on fire; recurring
|
|
5
|
+
ones stay active (but only fire again after the price leaves the
|
|
6
|
+
triggered region, so we don't spam).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
Op = Literal[">", "<"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Alert:
|
|
21
|
+
id: int
|
|
22
|
+
asset_class: str
|
|
23
|
+
symbol: str
|
|
24
|
+
op: Op
|
|
25
|
+
threshold: float
|
|
26
|
+
recurring: bool
|
|
27
|
+
active: bool
|
|
28
|
+
created_at: str
|
|
29
|
+
last_fired_at: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _now_utc_iso() -> str:
|
|
33
|
+
return datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create(
|
|
37
|
+
conn: sqlite3.Connection,
|
|
38
|
+
*,
|
|
39
|
+
asset_class: str,
|
|
40
|
+
symbol: str,
|
|
41
|
+
op: Op,
|
|
42
|
+
threshold: float,
|
|
43
|
+
recurring: bool = False,
|
|
44
|
+
) -> Alert:
|
|
45
|
+
cur = conn.execute(
|
|
46
|
+
"""
|
|
47
|
+
INSERT INTO alerts
|
|
48
|
+
(asset_class, symbol, op, threshold, recurring, active, created_at)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)
|
|
50
|
+
""",
|
|
51
|
+
(asset_class, symbol, op, threshold, int(recurring), _now_utc_iso()),
|
|
52
|
+
)
|
|
53
|
+
return get(conn, cur.lastrowid)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get(conn: sqlite3.Connection, alert_id: int) -> Alert:
|
|
57
|
+
row = conn.execute(
|
|
58
|
+
"SELECT * FROM alerts WHERE id = ?", (alert_id,)
|
|
59
|
+
).fetchone()
|
|
60
|
+
if row is None:
|
|
61
|
+
raise KeyError(alert_id)
|
|
62
|
+
return _row_to_alert(row)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_active(conn: sqlite3.Connection) -> list[Alert]:
|
|
66
|
+
rows = conn.execute(
|
|
67
|
+
"SELECT * FROM alerts WHERE active = 1 ORDER BY id"
|
|
68
|
+
).fetchall()
|
|
69
|
+
return [_row_to_alert(r) for r in rows]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_all(conn: sqlite3.Connection) -> list[Alert]:
|
|
73
|
+
rows = conn.execute("SELECT * FROM alerts ORDER BY active DESC, id").fetchall()
|
|
74
|
+
return [_row_to_alert(r) for r in rows]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def delete(conn: sqlite3.Connection, alert_id: int) -> bool:
|
|
78
|
+
cur = conn.execute("DELETE FROM alerts WHERE id = ?", (alert_id,))
|
|
79
|
+
return cur.rowcount > 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def deactivate(conn: sqlite3.Connection, alert_id: int) -> None:
|
|
83
|
+
conn.execute("UPDATE alerts SET active = 0 WHERE id = ?", (alert_id,))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def record_fire(
|
|
87
|
+
conn: sqlite3.Connection, alert_id: int, price: float
|
|
88
|
+
) -> None:
|
|
89
|
+
ts = _now_utc_iso()
|
|
90
|
+
conn.execute(
|
|
91
|
+
"INSERT INTO fires (alert_id, fired_at, price) VALUES (?, ?, ?)",
|
|
92
|
+
(alert_id, ts, price),
|
|
93
|
+
)
|
|
94
|
+
conn.execute(
|
|
95
|
+
"UPDATE alerts SET last_fired_at = ? WHERE id = ?",
|
|
96
|
+
(ts, alert_id),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def count_active(conn: sqlite3.Connection) -> int:
|
|
101
|
+
return conn.execute(
|
|
102
|
+
"SELECT COUNT(*) FROM alerts WHERE active = 1"
|
|
103
|
+
).fetchone()[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _row_to_alert(row: sqlite3.Row) -> Alert:
|
|
107
|
+
return Alert(
|
|
108
|
+
id=row["id"],
|
|
109
|
+
asset_class=row["asset_class"],
|
|
110
|
+
symbol=row["symbol"],
|
|
111
|
+
op=row["op"],
|
|
112
|
+
threshold=row["threshold"],
|
|
113
|
+
recurring=bool(row["recurring"]),
|
|
114
|
+
active=bool(row["active"]),
|
|
115
|
+
created_at=row["created_at"],
|
|
116
|
+
last_fired_at=row["last_fired_at"],
|
|
117
|
+
)
|
terminex/alerts_ui.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Rendering for alert creation modal and alert list view."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from . import theme
|
|
10
|
+
from .alerts import Alert
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_alert_new_panel(draft: dict) -> Panel:
|
|
14
|
+
table = Table(show_header=False, box=None, padding=(0, 1), expand=False)
|
|
15
|
+
table.add_column("")
|
|
16
|
+
|
|
17
|
+
asset_class = draft["asset_class"]
|
|
18
|
+
symbol = draft["symbol"]
|
|
19
|
+
current = draft["current_price"]
|
|
20
|
+
|
|
21
|
+
table.add_row(
|
|
22
|
+
Text(
|
|
23
|
+
f"Create alert on {asset_class.upper()}:{symbol} "
|
|
24
|
+
f"(current: {_fmt(current)})",
|
|
25
|
+
style=f"bold {theme.ACCENT}",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
table.add_row("")
|
|
29
|
+
|
|
30
|
+
# threshold line
|
|
31
|
+
line = Text()
|
|
32
|
+
line.append(" Condition: price is ", style=theme.MUTED)
|
|
33
|
+
line.append(draft["op"], style=f"bold {theme.WARN}")
|
|
34
|
+
line.append(" ", style="")
|
|
35
|
+
line.append(draft["threshold_buffer"], style="bold white")
|
|
36
|
+
line.append("▏", style=f"bold {theme.WARN}")
|
|
37
|
+
table.add_row(line)
|
|
38
|
+
|
|
39
|
+
recurring_line = Text()
|
|
40
|
+
recurring_line.append(" Recurring: ", style=theme.MUTED)
|
|
41
|
+
recurring_line.append(
|
|
42
|
+
"yes" if draft["recurring"] else "no",
|
|
43
|
+
style=f"bold {theme.ACCENT}" if draft["recurring"] else theme.MUTED,
|
|
44
|
+
)
|
|
45
|
+
table.add_row(recurring_line)
|
|
46
|
+
|
|
47
|
+
table.add_row("")
|
|
48
|
+
if draft.get("error"):
|
|
49
|
+
table.add_row(Text(draft["error"], style=theme.ERROR))
|
|
50
|
+
table.add_row("")
|
|
51
|
+
table.add_row(
|
|
52
|
+
Text(
|
|
53
|
+
"0-9 . edit threshold · < > toggle op · r toggle recurring",
|
|
54
|
+
style=theme.MUTED,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
table.add_row(
|
|
58
|
+
Text("Enter to create · Esc to cancel", style=theme.MUTED)
|
|
59
|
+
)
|
|
60
|
+
return Panel(
|
|
61
|
+
table,
|
|
62
|
+
title="terminex — new alert",
|
|
63
|
+
title_align="left",
|
|
64
|
+
border_style=theme.PANEL_BORDER_WARN,
|
|
65
|
+
padding=(1, 2),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_alert_list_panel(
|
|
70
|
+
alerts: list[Alert], delete_buffer: str = ""
|
|
71
|
+
) -> Panel:
|
|
72
|
+
if not alerts:
|
|
73
|
+
inner: Table | Text = Text("No alerts defined.", style=theme.MUTED)
|
|
74
|
+
else:
|
|
75
|
+
tbl = Table(show_header=True, box=None, padding=(0, 2), expand=False)
|
|
76
|
+
tbl.add_column("#", style=theme.MUTED, justify="right")
|
|
77
|
+
tbl.add_column("Status", style=theme.MUTED)
|
|
78
|
+
tbl.add_column("Asset", style=theme.MUTED)
|
|
79
|
+
tbl.add_column("Symbol", style="bold")
|
|
80
|
+
tbl.add_column("Condition")
|
|
81
|
+
tbl.add_column("Mode", style=theme.MUTED)
|
|
82
|
+
tbl.add_column("Last fired", style=theme.MUTED)
|
|
83
|
+
for a in alerts:
|
|
84
|
+
status = Text(
|
|
85
|
+
"active" if a.active else "done",
|
|
86
|
+
style=theme.UP if a.active else theme.MUTED,
|
|
87
|
+
)
|
|
88
|
+
condition = Text(
|
|
89
|
+
f"{a.op} {a.threshold:g}", style=f"bold {theme.WARN}"
|
|
90
|
+
)
|
|
91
|
+
mode = "recurring" if a.recurring else "once"
|
|
92
|
+
last = a.last_fired_at.split("T")[0] if a.last_fired_at else "—"
|
|
93
|
+
tbl.add_row(
|
|
94
|
+
str(a.id), status, a.asset_class, a.symbol, condition, mode, last
|
|
95
|
+
)
|
|
96
|
+
inner = tbl
|
|
97
|
+
|
|
98
|
+
body = Table(show_header=False, box=None, padding=(0, 0), expand=False)
|
|
99
|
+
body.add_column("")
|
|
100
|
+
body.add_row(inner)
|
|
101
|
+
body.add_row("")
|
|
102
|
+
# delete prompt
|
|
103
|
+
prompt = Text()
|
|
104
|
+
prompt.append("delete ID: ", style=f"bold {theme.ACCENT}")
|
|
105
|
+
prompt.append(delete_buffer, style="bold white")
|
|
106
|
+
prompt.append("▏", style=f"bold {theme.WARN}")
|
|
107
|
+
body.add_row(prompt)
|
|
108
|
+
body.add_row("")
|
|
109
|
+
body.add_row(
|
|
110
|
+
Text(
|
|
111
|
+
"digits build ID · Enter deletes · Esc to close",
|
|
112
|
+
style=theme.MUTED,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
return Panel(
|
|
116
|
+
body,
|
|
117
|
+
title="terminex — alerts",
|
|
118
|
+
title_align="left",
|
|
119
|
+
border_style=theme.PANEL_BORDER_NEUTRAL,
|
|
120
|
+
padding=(1, 2),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _fmt(value: float) -> str:
|
|
125
|
+
if value >= 1000:
|
|
126
|
+
return f"{value:,.2f}"
|
|
127
|
+
if value >= 1:
|
|
128
|
+
return f"{value:.4f}"
|
|
129
|
+
return f"{value:.6f}"
|