Skip to content

Commit 038633e

Browse files
authored
Merge pull request #101 from lwalden/feature/s6-001-income-reinvest-wiring
feat: wire monthly income reinvest — recommendation-only default (S6-001)
2 parents fb647a1 + 6fa381e commit 038633e

12 files changed

Lines changed: 1743 additions & 35 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace TradingSystem.Core.Configuration;
2+
3+
/// <summary>
4+
/// Owner-gated operational config for the income sleeve timer (S6-001, Default D5). Bound
5+
/// from the "IncomeSleeve" config section (ReportingConfig precedent). This is an operational
6+
/// gate, NOT risk config — it must never live in TradingSystemConfig/RiskConfig/IncomeConfig,
7+
/// and changing it never alters any deterministic trading rule, cap, or sleeve allocation.
8+
/// </summary>
9+
public class IncomeSleeveConfig
10+
{
11+
/// <summary>
12+
/// LOCKED DECISION (S6 #1): the default posture places NO orders. The monthly reinvest
13+
/// timer always computes the ReinvestmentPlan and sends the Discord report; only when the
14+
/// OWNER explicitly flips this to true does the existing paper limit-order path
15+
/// (IncomeSleeveManager.ExecuteReinvestmentPlanAsync) engage. The income sleeve's PDR-004
16+
/// ≥12-week clock starts at its first actual trade — not at plan generation.
17+
/// </summary>
18+
public bool OrderPlacementEnabled { get; set; } = false;
19+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
namespace TradingSystem.Core.Interfaces;
2+
3+
/// <summary>
4+
/// Monthly income-sleeve reinvest pipeline (S6-001, Default D2 — the S5-001 thin-timer shape):
5+
/// first-trading-weekday gate (in code, never trusted to NCRONTAB — Default D3) → broker
6+
/// connect → recommendation plan via the existing IncomeSleeveManager (caps enforced at plan
7+
/// time; never duplicated) → flag-gated paper order placement
8+
/// (<see cref="TradingSystem.Core.Configuration.IncomeSleeveConfig.OrderPlacementEnabled"/>,
9+
/// default FALSE — locked decision 1: the default posture places NO orders) → best-effort
10+
/// Discord plan report. The timer wrapper resolves this null-tolerantly (ADR-024) and owns
11+
/// the catch → operational-alert → rethrow contract (Default D7).
12+
/// </summary>
13+
public interface IIncomeReinvestService
14+
{
15+
/// <summary>
16+
/// Runs the monthly reinvest pipeline. <paramref name="utcNow"/> comes from the timer's
17+
/// injected clock seam (S6-007, Default D1) so the first-weekday gate is testable.
18+
/// </summary>
19+
Task<IncomeReinvestResult> RunAsync(string runId, DateTime utcNow, CancellationToken cancellationToken = default);
20+
}
21+
22+
/// <summary>
23+
/// Structured outcome of a monthly reinvest run (EndOfDayResult precedent). Consumed by the
24+
/// timer wrapper's result log and by tests — notably the sprint's primary invariant:
25+
/// flag off ⇒ <see cref="OrdersPlaced"/> is 0 and IExecutionService is never called.
26+
/// </summary>
27+
public class IncomeReinvestResult
28+
{
29+
/// <summary>True when the first-trading-weekday gate (Default D3) skipped the run.</summary>
30+
public bool Skipped { get; set; }
31+
32+
/// <summary>Why the run was skipped (gate day mismatch). Null when not skipped.</summary>
33+
public string? SkipReason { get; set; }
34+
35+
/// <summary>Whether the broker connection succeeded. False → no plan, no report (Default D7).</summary>
36+
public bool BrokerConnected { get; set; }
37+
38+
/// <summary>Whether a ReinvestmentPlan was generated (an EMPTY plan still counts — Default D8).</summary>
39+
public bool PlanGenerated { get; set; }
40+
41+
/// <summary>Number of proposed buys in the plan (0 is a legitimate outcome — Default D8).</summary>
42+
public int ProposedBuyCount { get; set; }
43+
44+
/// <summary>Total dollar amount across proposed buys.</summary>
45+
public decimal TotalProposedAmount { get; set; }
46+
47+
/// <summary>
48+
/// Paper orders actually placed. ALWAYS 0 when
49+
/// IncomeSleeve:OrderPlacementEnabled is false (locked decision 1).
50+
/// </summary>
51+
public int OrdersPlaced { get; set; }
52+
53+
/// <summary>Whether the plan report send path completed without error (best-effort, Default D6).</summary>
54+
public bool ReportSent { get; set; }
55+
56+
/// <summary>Non-fatal degradations (e.g. report delivery failure). The run still succeeds with these present.</summary>
57+
public List<string> Warnings { get; set; } = new();
58+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using TradingSystem.Core.Models;
2+
3+
namespace TradingSystem.Core.Interfaces;
4+
5+
/// <summary>
6+
/// Sends the monthly income reinvest plan report (S6-001, Default D6): a digest-class
7+
/// (neutral color) Discord message — NOT an orange operational alert. Sent on every
8+
/// non-skipped, connected reinvest run, including empty plans ("no buys proposed" is proof
9+
/// the timer ran — Default D8). Observability, never control: implementations must degrade
10+
/// to logs (no throw beyond caller cancellation) on delivery failure, and callers treat the
11+
/// send as best-effort — a report failure can never fail the run or influence any trading
12+
/// path. No secrets and no exception messages ever render in the report content.
13+
/// </summary>
14+
public interface IIncomeReportService
15+
{
16+
/// <summary>
17+
/// Renders and sends the reinvestment plan report.
18+
/// <paramref name="ordersPlaced"/> is the number of paper orders actually placed
19+
/// (always 0 under the default recommendation-only posture — locked decision 1).
20+
/// </summary>
21+
Task SendReinvestmentPlanReportAsync(
22+
ReinvestmentPlan plan,
23+
IncomeSleeveState state,
24+
int ordersPlaced,
25+
CancellationToken cancellationToken = default);
26+
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
using System.Globalization;
2+
using System.Net.Http.Json;
3+
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Options;
5+
using TradingSystem.Core.Configuration;
6+
using TradingSystem.Core.Interfaces;
7+
using TradingSystem.Core.Models;
8+
9+
namespace TradingSystem.Functions;
10+
11+
/// <summary>
12+
/// S6-001 (Default D6): renders the monthly income reinvest plan as a digest-class NEUTRAL
13+
/// embed — deliberately NOT risk red or operational orange, because this message is routine
14+
/// evidence, not an alarm. Same webhook/config as the other Discord senders (no new secret)
15+
/// but its OWN named client ("DiscordIncomeReport", 8s timeout in Program.cs) so the senders'
16+
/// handlers/telemetry stay distinguishable. Reuses the S3-004 hardening verbatim via
17+
/// <see cref="DiscordWebhookGuard"/>: https-only Discord host allow-list, token-redacted
18+
/// logging ({scheme}://{host} only), bounded 429/Retry-After retry with an injectable delay
19+
/// (zero-wait in tests), and the Enabled==false skip (one-time ctor Information notice,
20+
/// per-call Debug skip — B-006 pattern).
21+
///
22+
/// Content contract: the posture line (recommendation-only vs orders-placed — locked
23+
/// decision 1), the D4 available-cash input labeled as an estimate, sleeve total value,
24+
/// per-category drift, and the proposed buys (capped render). An empty plan renders an
25+
/// explicit "No buys proposed" line (Default D8 — proof the timer ran). No secrets and no
26+
/// exception messages ever appear in any embed or log. Delivery failure degrades to a loud
27+
/// AlertDropped=true log and returns — a missed report is never an operational stop.
28+
/// </summary>
29+
public class DiscordIncomeReportService : IIncomeReportService
30+
{
31+
// Retries are bounded by BOTH a retry count and a cumulative wait budget (S3-004 pattern).
32+
private const int MaxRetries = 3;
33+
private const double MaxTotalWaitSeconds = 10.0;
34+
35+
// Cap on rendered buy lines (Discord field limit is 1024 chars; the report is a digest).
36+
private const int MaxRenderedBuys = 10;
37+
38+
// Digest-class neutral grey — never risk red (15158332) or ops orange (15105570).
39+
private const int NeutralColor = 9807270;
40+
41+
private readonly HttpClient _httpClient;
42+
private readonly DiscordConfig _config;
43+
private readonly IncomeSleeveConfig _sleeveConfig;
44+
private readonly ILogger<DiscordIncomeReportService> _logger;
45+
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
46+
47+
public DiscordIncomeReportService(
48+
IHttpClientFactory httpClientFactory,
49+
IOptions<DiscordConfig> config,
50+
IOptions<IncomeSleeveConfig> sleeveConfig,
51+
ILogger<DiscordIncomeReportService> logger)
52+
: this(httpClientFactory, config, sleeveConfig, logger, Task.Delay)
53+
{
54+
}
55+
56+
// Internal ctor: the delay is injectable so tests run with zero real wait.
57+
internal DiscordIncomeReportService(
58+
IHttpClientFactory httpClientFactory,
59+
IOptions<DiscordConfig> config,
60+
IOptions<IncomeSleeveConfig> sleeveConfig,
61+
ILogger<DiscordIncomeReportService> logger,
62+
Func<TimeSpan, CancellationToken, Task> delay)
63+
{
64+
_httpClient = httpClientFactory.CreateClient("DiscordIncomeReport");
65+
_config = config.Value;
66+
_sleeveConfig = sleeveConfig.Value;
67+
_logger = logger;
68+
_delay = delay;
69+
70+
if (!_config.Enabled)
71+
{
72+
// ONE-TIME Information-level notice at construction (B-006 pattern): without this
73+
// the disabled state of the reinvest report path would be invisible at the
74+
// Information minimum level; the per-call skip stays at Debug.
75+
_logger.LogInformation(
76+
"Discord income reinvest reports are disabled (Enabled=false); reinvest plan reports will NOT be delivered until enabled.");
77+
}
78+
}
79+
80+
public async Task SendReinvestmentPlanReportAsync(
81+
ReinvestmentPlan plan,
82+
IncomeSleeveState state,
83+
int ordersPlaced,
84+
CancellationToken cancellationToken = default)
85+
{
86+
var day = plan.PlanDate.Date;
87+
88+
if (!_config.Enabled)
89+
{
90+
_logger.LogDebug(
91+
"Discord income reinvest reports are disabled; skipping report for {Date}", day);
92+
return;
93+
}
94+
95+
if (string.IsNullOrWhiteSpace(_config.WebhookUrl))
96+
{
97+
_logger.LogWarning(
98+
"Discord webhook URL is not configured; cannot send income reinvest report for {Date}", day);
99+
return;
100+
}
101+
102+
// Host allow-list + scheme check. On rejection only a redacted {scheme}://{host} (or
103+
// an <empty>/<malformed> sentinel) is logged — never the token-bearing path.
104+
if (!DiscordWebhookGuard.TryValidateWebhook(_config.WebhookUrl, out var redacted))
105+
{
106+
_logger.LogWarning(
107+
"Discord webhook URL is not an allowed https Discord endpoint ({Redacted}); skipping income reinvest report for {Date}",
108+
redacted,
109+
day);
110+
return;
111+
}
112+
113+
var payload = new
114+
{
115+
username = _config.Username,
116+
// Disable mention parsing so report content can never ping @everyone/@here.
117+
allowed_mentions = new { parse = Array.Empty<string>() },
118+
embeds = new[] { BuildPlanEmbed(plan, state, ordersPlaced) }
119+
};
120+
121+
await PostWithRetryAsync(payload, day, cancellationToken);
122+
}
123+
124+
private Embed BuildPlanEmbed(ReinvestmentPlan plan, IncomeSleeveState state, int ordersPlaced)
125+
{
126+
// Posture leads (locked decision 1): the owner's first read must answer "did this
127+
// place orders?". The exact flag key renders so the runbook check is unambiguous.
128+
var posture = _sleeveConfig.OrderPlacementEnabled
129+
? $"Order placement ENABLED — {ordersPlaced.ToString(CultureInfo.InvariantCulture)} paper order(s) placed (IncomeSleeve:OrderPlacementEnabled=true)."
130+
: "Recommendation-only; IncomeSleeve:OrderPlacementEnabled=false. NO orders were placed.";
131+
132+
var fields = new List<EmbedField>
133+
{
134+
new("Posture", posture, false),
135+
// Default D4: the cash input is a heuristic estimate (TotalCashValue ×
136+
// IncomeTargetPercent) — labeled so nobody mistakes it for dividend tracking.
137+
new("Available Cash (estimate)",
138+
$"{Money(plan.AvailableCash)} — TotalCashValue × IncomeTargetPercent (estimate; dividend/interest tracking not yet wired)",
139+
true),
140+
new("Sleeve Total Value", Money(state.TotalValue), true)
141+
};
142+
143+
if (state.CategoryDrift.Count > 0)
144+
{
145+
var driftLines = state.CategoryDrift
146+
.OrderBy(kv => kv.Value)
147+
.Select(kv => $"{kv.Key}: {kv.Value.ToString("+0.0%;-0.0%;0.0%", CultureInfo.InvariantCulture)}");
148+
fields.Add(new EmbedField("Category Drift (current − target)",
149+
string.Join(Environment.NewLine, driftLines), false));
150+
}
151+
152+
if (plan.ProposedBuys.Count == 0)
153+
{
154+
// Default D8: an empty plan is a legitimate outcome (e.g. an unfunded sleeve) —
155+
// say so explicitly; this message existing at all is proof the timer ran.
156+
fields.Add(new EmbedField("Proposed Buys", "No buys proposed.", false));
157+
}
158+
else
159+
{
160+
var lines = plan.ProposedBuys
161+
.Take(MaxRenderedBuys)
162+
.Select(b => string.Create(
163+
CultureInfo.InvariantCulture,
164+
$"{b.Symbol} ({b.Category}): {Money(b.Amount)}{b.Rationale}"));
165+
var overflow = plan.ProposedBuys.Count > MaxRenderedBuys
166+
? $"{Environment.NewLine}... and {(plan.ProposedBuys.Count - MaxRenderedBuys).ToString(CultureInfo.InvariantCulture)} more"
167+
: string.Empty;
168+
fields.Add(new EmbedField("Proposed Buys",
169+
string.Join(Environment.NewLine, lines) + overflow, false));
170+
}
171+
172+
var description = plan.ProposedBuys.Count == 0
173+
? "No buys proposed — sleeve is unfunded or fully on-target."
174+
: $"{plan.ProposedBuys.Count.ToString(CultureInfo.InvariantCulture)} proposed buy(s) totaling {Money(plan.TotalProposedAmount)}.";
175+
176+
return new Embed(
177+
title: $"Income Reinvest Plan — {plan.PlanDate.ToString("ddd MMM d, yyyy", CultureInfo.InvariantCulture)}",
178+
description: description,
179+
color: NeutralColor,
180+
timestamp: plan.PlanDate.Date.ToString("O", CultureInfo.InvariantCulture),
181+
fields: fields.ToArray());
182+
}
183+
184+
// POSTs the payload, honoring a 429 Retry-After with a bounded backoff (count + cumulative
185+
// wait budget). Degrades to a loud AlertDropped=true log and returns (no throw) on non-2xx
186+
// exhaustion, timeout, or transport failure. Status code / exception TYPE only is logged —
187+
// never the webhook URL, token, or an exception message (which can echo the request URI).
188+
private async Task PostWithRetryAsync(object payload, DateTime day, CancellationToken cancellationToken)
189+
{
190+
var start = DateTime.UtcNow;
191+
var attempt = 0;
192+
while (true)
193+
{
194+
try
195+
{
196+
using var response = await _httpClient.PostAsJsonAsync(_config.WebhookUrl, payload, cancellationToken);
197+
if (response.IsSuccessStatusCode)
198+
{
199+
_logger.LogInformation("Sent Discord income reinvest report for {Date}", day);
200+
return;
201+
}
202+
203+
var remaining = MaxTotalWaitSeconds - (DateTime.UtcNow - start).TotalSeconds;
204+
if ((int)response.StatusCode != 429 || attempt >= MaxRetries || remaining <= 0)
205+
{
206+
// Terminal failure: the report is gone and will NOT be retried — make it
207+
// loud and log-searchable (AlertDropped=true) so the 2026-07-01 run-record
208+
// check ("did the plan report arrive?") has a definitive negative signal.
209+
_logger.LogError(
210+
"Income reinvest report NOT delivered and will not be retried. AlertDropped={AlertDropped}, StatusCode={StatusCode}, Attempts={Attempt}/{MaxRetries}, Date={Date}",
211+
true,
212+
(int)response.StatusCode,
213+
attempt + 1,
214+
MaxRetries,
215+
day);
216+
return;
217+
}
218+
219+
var wait = Math.Max(0, Math.Min(DiscordWebhookGuard.RetryAfterSeconds(response), remaining));
220+
_logger.LogWarning(
221+
"Discord rate-limited (429); retrying income reinvest report for {Date} after {WaitSeconds:F2}s (attempt {Attempt}/{Max})",
222+
day,
223+
wait,
224+
attempt + 1,
225+
MaxRetries);
226+
await _delay(TimeSpan.FromSeconds(wait), cancellationToken);
227+
attempt++;
228+
}
229+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
230+
{
231+
// Caller cancelled — propagate cancellation, it carries no token.
232+
throw;
233+
}
234+
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
235+
{
236+
// HttpClient timeout (the 8s named-client bound): the report never reached
237+
// Discord and will NOT be retried. Type names only.
238+
_logger.LogError(
239+
"Income reinvest report NOT delivered and will not be retried — send timed out. AlertDropped={AlertDropped}, ErrorType={ErrorType}, InnerErrorType={InnerErrorType}, Date={Date}",
240+
true,
241+
ex.GetType().Name,
242+
ex.InnerException?.GetType().Name,
243+
day);
244+
return;
245+
}
246+
catch (HttpRequestException ex)
247+
{
248+
// Transport failure. NEVER log ex.Message / ex.ToString() — those can echo
249+
// the token-bearing request URI.
250+
_logger.LogError(
251+
"Income reinvest report NOT delivered and will not be retried — transport error. AlertDropped={AlertDropped}, ErrorType={ErrorType}, StatusCode={StatusCode}, InnerErrorType={InnerErrorType}, Date={Date}",
252+
true,
253+
ex.GetType().Name,
254+
ex.StatusCode,
255+
ex.InnerException?.GetType().Name,
256+
day);
257+
return;
258+
}
259+
}
260+
}
261+
262+
private static string Money(decimal value) =>
263+
value < 0
264+
? string.Create(CultureInfo.InvariantCulture, $"-${Math.Abs(value):N2}")
265+
: string.Create(CultureInfo.InvariantCulture, $"${value:N2}");
266+
267+
// Discord embed shapes (lowercase property names match the webhook JSON contract).
268+
private sealed record Embed(
269+
string title,
270+
string description,
271+
int color,
272+
string timestamp,
273+
EmbedField[] fields);
274+
275+
private sealed record EmbedField(string name, string value, bool inline);
276+
}

src/TradingSystem.Functions/DiscordRiskAlertService.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ public class DiscordRiskAlertService : IRiskAlertService, IOperationalAlertServi
3939

4040
// Terminal-drop guidance per alert kind. The risk wording is unchanged (a missed stop alert
4141
// demands manual verification); the operational wording is softer — an ops alert is not a
42-
// capital-preservation stop, the run outcome itself is already in logs/App Insights.
42+
// capital-preservation stop, the run outcome itself is already in the worker logs.
43+
// Application Insights is OFF by design (KD-006), so triage points at the local logs.
4344
private const string RiskDroppedNote = "verify the risk stop was acted on manually";
44-
private const string OperationalDroppedNote = "check the run outcome in logs/Application Insights";
45+
private const string OperationalDroppedNote =
46+
@"check the run outcome in the worker console or %LOCALAPPDATA%\TradingSystem\logs";
4547

4648
private readonly HttpClient _httpClient;
4749
private readonly DiscordConfig _config;

0 commit comments

Comments
 (0)