support live streaming on portfolio tab
This commit is contained in:
parent
eadba4696c
commit
051ab35975
1 changed files with 184 additions and 2 deletions
|
|
@ -140,6 +140,11 @@ pub const Action = enum {
|
||||||
/// on the same symbol; re-target when open and cursor is on
|
/// on the same symbol; re-target when open and cursor is on
|
||||||
/// a different symbol.
|
/// a different symbol.
|
||||||
toggle_overlay,
|
toggle_overlay,
|
||||||
|
/// Toggle opt-in live streaming: subscribe all held + watchlist
|
||||||
|
/// symbols to the websocket feed and re-value the portfolio on
|
||||||
|
/// each tick. Off by default. See the streaming helpers below for
|
||||||
|
/// the scope of what updates live.
|
||||||
|
toggle_stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tab-private state ─────────────────────────────────────────
|
// ── Tab-private state ─────────────────────────────────────────
|
||||||
|
|
@ -256,6 +261,17 @@ pub const State = struct {
|
||||||
/// `handleMouse` / `drawContent` / `drawStatusBar` check
|
/// `handleMouse` / `drawContent` / `drawStatusBar` check
|
||||||
/// this and route accordingly.
|
/// this and route accordingly.
|
||||||
modal: Modal = .none,
|
modal: Modal = .none,
|
||||||
|
|
||||||
|
/// Opt-in live streaming (default off). Toggled with
|
||||||
|
/// `toggle_stream`. While true the App polls `tick`, which snapshots
|
||||||
|
/// the streamed prices and calls `PortfolioData.revalue` to refresh
|
||||||
|
/// the position table + totals. The stream is owned by
|
||||||
|
/// `DataService`; torn down on `deactivate` / `deinit`.
|
||||||
|
streaming: bool = false,
|
||||||
|
/// Owned, duped symbols subscribed to the stream (held + watchlist),
|
||||||
|
/// captured when streaming starts so the per-tick snapshot has
|
||||||
|
/// stable key strings. Freed (and emptied) when streaming stops.
|
||||||
|
stream_syms: [][]const u8 = &.{},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tab framework contract ────────────────────────────────────
|
// ── Tab framework contract ────────────────────────────────────
|
||||||
|
|
@ -275,6 +291,7 @@ pub const meta: framework.TabMeta(Action) = .{
|
||||||
// (or re-targets) the symbol-info overlay for the
|
// (or re-targets) the symbol-info overlay for the
|
||||||
// cursor row's symbol.
|
// cursor row's symbol.
|
||||||
.{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } },
|
.{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } },
|
||||||
|
.{ .action = .toggle_stream, .key = .{ .codepoint = 'L' } },
|
||||||
},
|
},
|
||||||
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||||||
.expand_collapse = "Expand/collapse position",
|
.expand_collapse = "Expand/collapse position",
|
||||||
|
|
@ -285,6 +302,7 @@ pub const meta: framework.TabMeta(Action) = .{
|
||||||
.clear_account_filter = "Clear account filter",
|
.clear_account_filter = "Clear account filter",
|
||||||
.select_symbol = "Select symbol",
|
.select_symbol = "Select symbol",
|
||||||
.toggle_overlay = "Show symbol details",
|
.toggle_overlay = "Show symbol details",
|
||||||
|
.toggle_stream = "Toggle live stream",
|
||||||
}),
|
}),
|
||||||
.status_hints = &.{
|
.status_hints = &.{
|
||||||
.sort_col_prev,
|
.sort_col_prev,
|
||||||
|
|
@ -292,6 +310,7 @@ pub const meta: framework.TabMeta(Action) = .{
|
||||||
.sort_reverse,
|
.sort_reverse,
|
||||||
.open_account_picker,
|
.open_account_picker,
|
||||||
.toggle_overlay,
|
.toggle_overlay,
|
||||||
|
.toggle_stream,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -305,6 +324,7 @@ pub const tab = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(state: *State, app: *App) void {
|
pub fn deinit(state: *State, app: *App) void {
|
||||||
|
if (state.streaming) stopStreaming(state, app);
|
||||||
state.rows.deinit(app.allocator);
|
state.rows.deinit(app.allocator);
|
||||||
if (state.prepared_options) |*opts| opts.deinit();
|
if (state.prepared_options) |*opts| opts.deinit();
|
||||||
if (state.prepared_cds) |*cds| cds.deinit();
|
if (state.prepared_cds) |*cds| cds.deinit();
|
||||||
|
|
@ -328,7 +348,20 @@ pub const tab = struct {
|
||||||
loadPortfolioData(state, app);
|
loadPortfolioData(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const deactivate = framework.noopDeactivate(State);
|
/// Leaving the tab stops any live stream so it doesn't keep a
|
||||||
|
/// socket + thread alive in the background (and frees the single
|
||||||
|
/// shared `DataService` stream for another tab). Re-enabled by
|
||||||
|
/// toggling again on return.
|
||||||
|
pub fn deactivate(state: *State, app: *App) void {
|
||||||
|
if (state.streaming) stopStreaming(state, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Framework poll-tick gate: true only while streaming, so the App
|
||||||
|
/// keeps a 100ms tick armed to pull fresh prices and re-value.
|
||||||
|
pub fn wantsPollTick(state: *State, app: *App) bool {
|
||||||
|
_ = app;
|
||||||
|
return state.streaming;
|
||||||
|
}
|
||||||
|
|
||||||
/// Disabled only when the TUI was opened on a specific symbol
|
/// Disabled only when the TUI was opened on a specific symbol
|
||||||
/// (`zfin AAPL`) with no portfolio loaded: there's nothing to show
|
/// (`zfin AAPL`) with no portfolio loaded: there's nothing to show
|
||||||
|
|
@ -404,7 +437,26 @@ pub const tab = struct {
|
||||||
loadPortfolioData(state, app);
|
loadPortfolioData(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const tick = framework.noopTick(State);
|
/// Poll hook: while streaming, snapshot the latest streamed prices
|
||||||
|
/// for the subscribed symbols, re-value the portfolio against them
|
||||||
|
/// (cheap - no file I/O), and rebuild the row table. The historical-
|
||||||
|
/// return block and worker-backed data stay as of the last full
|
||||||
|
/// load; only the position table + totals reflect the live prices.
|
||||||
|
pub fn tick(state: *State, app: *App, frame: u64) void {
|
||||||
|
_ = frame;
|
||||||
|
if (!state.streaming or state.stream_syms.len == 0) return;
|
||||||
|
var overlay = std.StringHashMap(f64).init(app.allocator);
|
||||||
|
defer overlay.deinit();
|
||||||
|
app.svc.liveStreamSnapshot(state.stream_syms, &overlay);
|
||||||
|
if (overlay.count() == 0) return; // no ticks yet; nothing to re-value
|
||||||
|
if (app.portfolio.revalue(app.today, &overlay)) {
|
||||||
|
// wall-clock required: stamp the live re-value instant so the
|
||||||
|
// portfolio footer's "(as of H:MM PM ET)" reflects the stream,
|
||||||
|
// not the last full load.
|
||||||
|
app.portfolio.live_quotes_at_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
||||||
|
loadPortfolioData(state, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|
@ -473,6 +525,20 @@ pub const tab = struct {
|
||||||
};
|
};
|
||||||
app.toggleOverlay(sym);
|
app.toggleOverlay(sym);
|
||||||
},
|
},
|
||||||
|
.toggle_stream => {
|
||||||
|
if (state.streaming) {
|
||||||
|
stopStreaming(state, app);
|
||||||
|
app.setStatus("Live stream: off");
|
||||||
|
} else if (app.portfolio.file == null) {
|
||||||
|
app.setStatus("No portfolio to stream");
|
||||||
|
} else if (startStreaming(state, app)) |n| {
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(&buf, "Live stream: on ({d} symbols, Yahoo)", .{n}) catch "Live stream: on";
|
||||||
|
app.setStatus(msg);
|
||||||
|
} else {
|
||||||
|
app.setStatus("Live stream: nothing to subscribe");
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -759,6 +825,84 @@ fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style {
|
||||||
/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded`
|
/// is `app.portfolio.loaded` which `ensurePortfolioDataLoaded`
|
||||||
/// owns. Visiting portfolio after analysis pre-loaded the data
|
/// owns. Visiting portfolio after analysis pre-loaded the data
|
||||||
/// will still rebuild the row list - cheap.)
|
/// will still rebuild the row list - cheap.)
|
||||||
|
// ── Live-stream helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Gather held stock symbols + watchlist (deduped), dup them into
|
||||||
|
/// `state.stream_syms` (owned, so the per-tick snapshot has stable
|
||||||
|
/// keys), and start the live stream. Returns the subscribed symbol
|
||||||
|
/// count on success, or null when there's nothing to subscribe or the
|
||||||
|
/// stream failed to start (in which case `streaming` stays false).
|
||||||
|
fn startStreaming(state: *State, app: *App) ?usize {
|
||||||
|
const held_owned: ?[][]const u8 = if (app.portfolio.file) |pf|
|
||||||
|
(pf.stockSymbols(app.allocator) catch |err| ret: {
|
||||||
|
std.log.scoped(.tui).debug("stockSymbols for stream: {t}", .{err});
|
||||||
|
break :ret null;
|
||||||
|
})
|
||||||
|
else
|
||||||
|
null;
|
||||||
|
defer if (held_owned) |h| app.allocator.free(h); // outer slice; gather dups the strings
|
||||||
|
|
||||||
|
const owned = gatherStreamSymbols(
|
||||||
|
app.allocator,
|
||||||
|
held_owned orelse &.{},
|
||||||
|
app.watchlist orelse &.{},
|
||||||
|
) catch return null;
|
||||||
|
if (owned.len == 0) {
|
||||||
|
app.allocator.free(owned);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
app.svc.startLiveStream(owned) catch |err| {
|
||||||
|
std.log.scoped(.tui).warn("portfolio startLiveStream: {t}", .{err});
|
||||||
|
for (owned) |s| app.allocator.free(s);
|
||||||
|
app.allocator.free(owned);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
state.stream_syms = owned;
|
||||||
|
state.streaming = true;
|
||||||
|
return owned.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the owned, deduped subscribe list: every held symbol, then any
|
||||||
|
/// watchlist symbol not already present (held wins). Each string is
|
||||||
|
/// duped with `allocator`; the caller owns the result and its strings.
|
||||||
|
/// Pure (allocator + two slices), so it's unit-testable without an App.
|
||||||
|
fn gatherStreamSymbols(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
held: []const []const u8,
|
||||||
|
watchlist: []const []const u8,
|
||||||
|
) ![][]const u8 {
|
||||||
|
var syms: std.ArrayList([]const u8) = .empty;
|
||||||
|
errdefer {
|
||||||
|
for (syms.items) |s| allocator.free(s);
|
||||||
|
syms.deinit(allocator);
|
||||||
|
}
|
||||||
|
for (held) |sym| {
|
||||||
|
try syms.append(allocator, try allocator.dupe(u8, sym));
|
||||||
|
}
|
||||||
|
for (watchlist) |sym| {
|
||||||
|
var seen = false;
|
||||||
|
for (syms.items) |existing| {
|
||||||
|
if (std.mem.eql(u8, existing, sym)) {
|
||||||
|
seen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seen) continue;
|
||||||
|
try syms.append(allocator, try allocator.dupe(u8, sym));
|
||||||
|
}
|
||||||
|
return syms.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the live stream (if running) and free the subscribed symbols.
|
||||||
|
/// Idempotent.
|
||||||
|
fn stopStreaming(state: *State, app: *App) void {
|
||||||
|
app.svc.stopLiveStream();
|
||||||
|
for (state.stream_syms) |s| app.allocator.free(s);
|
||||||
|
app.allocator.free(state.stream_syms);
|
||||||
|
state.stream_syms = &.{};
|
||||||
|
state.streaming = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn loadPortfolioData(state: *State, app: *App) void {
|
pub fn loadPortfolioData(state: *State, app: *App) void {
|
||||||
// Summary is populated synchronously by pd.load; if it's
|
// Summary is populated synchronously by pd.load; if it's
|
||||||
// null here, no portfolio is loaded (welcome screen).
|
// null here, no portfolio is loaded (welcome screen).
|
||||||
|
|
@ -2557,3 +2701,41 @@ test "ensureCursorVisible: zero visible height is a no-op for the lower bound" {
|
||||||
// cursor_row = 0 < 5 -> scroll = 0. Lower-bound branch fires.
|
// cursor_row = 0 < 5 -> scroll = 0. Lower-bound branch fires.
|
||||||
try testing.expectEqual(@as(usize, 0), scroll);
|
try testing.expectEqual(@as(usize, 0), scroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "gatherStreamSymbols: held first, watchlist deduped against held" {
|
||||||
|
const a = testing.allocator;
|
||||||
|
const held = [_][]const u8{ "AAPL", "SPY" };
|
||||||
|
const watchlist = [_][]const u8{ "SPY", "QQQ", "AAPL", "VTI" };
|
||||||
|
const got = try gatherStreamSymbols(a, &held, &watchlist);
|
||||||
|
defer {
|
||||||
|
for (got) |s| a.free(s);
|
||||||
|
a.free(got);
|
||||||
|
}
|
||||||
|
// Held first (AAPL, SPY), then only watchlist entries not already
|
||||||
|
// present (QQQ, VTI); SPY and AAPL are deduped.
|
||||||
|
try testing.expectEqual(@as(usize, 4), got.len);
|
||||||
|
try testing.expectEqualStrings("AAPL", got[0]);
|
||||||
|
try testing.expectEqualStrings("SPY", got[1]);
|
||||||
|
try testing.expectEqualStrings("QQQ", got[2]);
|
||||||
|
try testing.expectEqualStrings("VTI", got[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "gatherStreamSymbols: empty held + watchlist yields empty list" {
|
||||||
|
const a = testing.allocator;
|
||||||
|
const got = try gatherStreamSymbols(a, &.{}, &.{});
|
||||||
|
defer a.free(got);
|
||||||
|
try testing.expectEqual(@as(usize, 0), got.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "gatherStreamSymbols: watchlist-only when no holdings" {
|
||||||
|
const a = testing.allocator;
|
||||||
|
const watchlist = [_][]const u8{ "BTC-USD", "ETH-USD" };
|
||||||
|
const got = try gatherStreamSymbols(a, &.{}, &watchlist);
|
||||||
|
defer {
|
||||||
|
for (got) |s| a.free(s);
|
||||||
|
a.free(got);
|
||||||
|
}
|
||||||
|
try testing.expectEqual(@as(usize, 2), got.len);
|
||||||
|
try testing.expectEqualStrings("BTC-USD", got[0]);
|
||||||
|
try testing.expectEqualStrings("ETH-USD", got[1]);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue