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 ADDED
@@ -0,0 +1 @@
1
+ """terminex — live FX rates in the terminal."""
terminex/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Entry point for ``python -m terminex``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .app import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -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}"