style projections based on need to rebalance portfolio

This commit is contained in:
Emil Lerch 2026-04-27 17:56:42 -07:00
parent 6debed0d69
commit 4f75c2e006
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 73 additions and 49 deletions

View file

@ -34,6 +34,18 @@ pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void {
}
}
/// Map a semantic StyleIntent to CLI ANSI color.
pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !void {
if (!c) return;
switch (intent) {
.normal => try reset(out, c),
.muted => try setFg(out, c, CLR_MUTED),
.positive => try setFg(out, c, CLR_POSITIVE),
.negative => try setFg(out, c, CLR_NEGATIVE),
.warning => try setFg(out, c, CLR_WARNING),
}
}
// Stderr helpers
pub fn stderrPrint(msg: []const u8) !void {

View file

@ -346,9 +346,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
switch (cells.style) {
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
// `normal` is unreachable in the windows block (build
// never emits it); no-op keeps the switch exhaustive.
.muted, .warning => try cli.setFg(out, color, cli.CLR_MUTED),
.normal => {},
}

View file

@ -6,13 +6,7 @@ const views = @import("../views/portfolio_sections.zig");
/// Map a semantic StyleIntent to CLI ANSI foreground color.
fn setIntentFg(out: *std.Io.Writer, color: bool, intent: fmt.StyleIntent) !void {
if (!color) return;
switch (intent) {
.normal => try cli.reset(out, color),
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
}
try cli.setStyleIntent(out, color, intent);
}
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {

View file

@ -203,8 +203,8 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
var note_buf: [128]u8 = undefined;
if (view.fmtAllocationNote(&note_buf, user_config.target_stock_pct, stock_pct)) |note| {
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s}\n", .{note});
try cli.setStyleIntent(out, color, note.style);
try out.print("{s}\n", .{note.text});
try cli.reset(out, color);
}
}
@ -269,7 +269,7 @@ fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void {
}
fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void {
try setIntentFg(out, color, cell.style);
try cli.setStyleIntent(out, color, cell.style);
switch (width) {
8 => try out.print("{s: >8}", .{cell.text}),
9 => try out.print("{s: >9}", .{cell.text}),
@ -279,15 +279,6 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi
try cli.reset(out, color);
}
fn setIntentFg(out: *std.Io.Writer, color: bool, intent: view.StyleIntent) !void {
switch (intent) {
.normal => try cli.reset(out, color),
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
}
}
fn candleDate(c: zfin.Candle) zfin.Date {
return c.date;
}

View file

@ -403,6 +403,7 @@ pub const StyleIntent = enum {
muted, // dim/secondary (expired items)
positive, // green (gains, premium received)
negative, // red (losses, premium paid)
warning, // yellow (stale data, drift)
};
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.

View file

@ -246,15 +246,7 @@ fn appendWindowsBlock(
" {s:<12} {s:>18} {s:>10}",
.{ cells.label, cells.delta_str, cells.pct_str },
);
const style: vaxis.Cell.Style = switch (cells.style) {
.positive => th.positiveStyle(),
.negative => th.negativeStyle(),
.muted => th.mutedStyle(),
// `normal` is unreachable in the windows block (build
// never emits it); fall back to content style to keep
// the switch exhaustive.
.normal => th.contentStyle(),
};
const style: vaxis.Cell.Style = th.styleFor(cells.style);
try lines.append(arena, .{ .text = text, .style = style });
}
}

View file

@ -35,12 +35,7 @@ const gl_col_start: usize = col_end_market_value;
/// Map a semantic StyleIntent to a platform-specific vaxis style.
fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
return switch (intent) {
.normal => th.contentStyle(),
.muted => th.mutedStyle(),
.positive => th.positiveStyle(),
.negative => th.negativeStyle(),
};
return th.styleFor(intent);
}
// Data loading

View file

@ -1,6 +1,7 @@
const std = @import("std");
const vaxis = @import("vaxis");
const srf = @import("srf");
const fmt = @import("../format.zig");
pub const Color = [3]u8;
@ -119,6 +120,17 @@ pub const Theme = struct {
return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) };
}
/// Map a semantic StyleIntent to a vaxis style.
pub fn styleFor(self: Theme, intent: fmt.StyleIntent) vaxis.Style {
return switch (intent) {
.normal => self.contentStyle(),
.muted => self.mutedStyle(),
.positive => self.positiveStyle(),
.negative => self.negativeStyle(),
.warning => self.warningStyle(),
};
}
pub fn barFillStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.bar_fill), .bg = vcolor(self.bg) };
}

View file

@ -108,21 +108,41 @@ pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 {
// Allocation summary
/// Format the target allocation note line.
/// Result of formatting the allocation note.
pub const AllocationNote = struct {
text: []const u8,
style: StyleIntent,
};
/// Format the target allocation note line with drift-aware styling.
/// Returns null if no target is configured.
pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?[]const u8 {
///
/// Drift thresholds:
/// - Within 2%: "on target" (muted)
/// - 25% off: warning
/// - Over 5% off: negative
pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote {
const target = target_stock_pct orelse return null;
const current = current_stock_pct * 100;
const diff = current - target;
const drift = @abs(current - target);
if (@abs(diff) < 2.0) {
return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{
const style: StyleIntent = if (drift < 2.0)
.muted
else if (drift < 5.0)
.warning
else
.negative;
const text = if (drift < 2.0)
std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{
target, 100.0 - target, current,
}) catch null;
}
return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{
target, 100.0 - target, current,
}) catch null;
}) catch return null
else
std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{
target, 100.0 - target, current,
}) catch return null;
return .{ .text = text, .style = style };
}
/// Format the stock benchmark label with weight (e.g. "SPY (83.8% weight)").
@ -168,14 +188,23 @@ test "fmtAllocationNote on target" {
var buf: [128]u8 = undefined;
const note = fmtAllocationNote(&buf, 77, 0.768);
try std.testing.expect(note != null);
try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") != null);
try std.testing.expect(note.?.style == .muted);
try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") != null);
}
test "fmtAllocationNote off target" {
var buf: [128]u8 = undefined;
const note = fmtAllocationNote(&buf, 77, 0.85);
try std.testing.expect(note != null);
try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") == null);
try std.testing.expect(note.?.style == .negative); // >5% drift
try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") == null);
}
test "fmtAllocationNote warning range" {
var buf: [128]u8 = undefined;
const note = fmtAllocationNote(&buf, 77, 0.80);
try std.testing.expect(note != null);
try std.testing.expect(note.?.style == .warning); // 3% drift, in 2-5% range
}
test "fmtAllocationNote no target" {