style projections based on need to rebalance portfolio
This commit is contained in:
parent
6debed0d69
commit
4f75c2e006
9 changed files with 73 additions and 49 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 => {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(¬e_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
/// - 2–5% 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" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue