diff --git a/src/TradingSystem.Core/Configuration/IncomeSleeveConfig.cs b/src/TradingSystem.Core/Configuration/IncomeSleeveConfig.cs
new file mode 100644
index 0000000..a6e530b
--- /dev/null
+++ b/src/TradingSystem.Core/Configuration/IncomeSleeveConfig.cs
@@ -0,0 +1,19 @@
+namespace TradingSystem.Core.Configuration;
+
+///
+/// Owner-gated operational config for the income sleeve timer (S6-001, Default D5). Bound
+/// from the "IncomeSleeve" config section (ReportingConfig precedent). This is an operational
+/// gate, NOT risk config — it must never live in TradingSystemConfig/RiskConfig/IncomeConfig,
+/// and changing it never alters any deterministic trading rule, cap, or sleeve allocation.
+///
+public class IncomeSleeveConfig
+{
+ ///
+ /// LOCKED DECISION (S6 #1): the default posture places NO orders. The monthly reinvest
+ /// timer always computes the ReinvestmentPlan and sends the Discord report; only when the
+ /// OWNER explicitly flips this to true does the existing paper limit-order path
+ /// (IncomeSleeveManager.ExecuteReinvestmentPlanAsync) engage. The income sleeve's PDR-004
+ /// ≥12-week clock starts at its first actual trade — not at plan generation.
+ ///
+ public bool OrderPlacementEnabled { get; set; } = false;
+}
diff --git a/src/TradingSystem.Core/Interfaces/IIncomeReinvestService.cs b/src/TradingSystem.Core/Interfaces/IIncomeReinvestService.cs
new file mode 100644
index 0000000..c7b9b2f
--- /dev/null
+++ b/src/TradingSystem.Core/Interfaces/IIncomeReinvestService.cs
@@ -0,0 +1,58 @@
+namespace TradingSystem.Core.Interfaces;
+
+///
+/// Monthly income-sleeve reinvest pipeline (S6-001, Default D2 — the S5-001 thin-timer shape):
+/// first-trading-weekday gate (in code, never trusted to NCRONTAB — Default D3) → broker
+/// connect → recommendation plan via the existing IncomeSleeveManager (caps enforced at plan
+/// time; never duplicated) → flag-gated paper order placement
+/// (,
+/// default FALSE — locked decision 1: the default posture places NO orders) → best-effort
+/// Discord plan report. The timer wrapper resolves this null-tolerantly (ADR-024) and owns
+/// the catch → operational-alert → rethrow contract (Default D7).
+///
+public interface IIncomeReinvestService
+{
+ ///
+ /// Runs the monthly reinvest pipeline. comes from the timer's
+ /// injected clock seam (S6-007, Default D1) so the first-weekday gate is testable.
+ ///
+ Task RunAsync(string runId, DateTime utcNow, CancellationToken cancellationToken = default);
+}
+
+///
+/// Structured outcome of a monthly reinvest run (EndOfDayResult precedent). Consumed by the
+/// timer wrapper's result log and by tests — notably the sprint's primary invariant:
+/// flag off ⇒ is 0 and IExecutionService is never called.
+///
+public class IncomeReinvestResult
+{
+ /// True when the first-trading-weekday gate (Default D3) skipped the run.
+ public bool Skipped { get; set; }
+
+ /// Why the run was skipped (gate day mismatch). Null when not skipped.
+ public string? SkipReason { get; set; }
+
+ /// Whether the broker connection succeeded. False → no plan, no report (Default D7).
+ public bool BrokerConnected { get; set; }
+
+ /// Whether a ReinvestmentPlan was generated (an EMPTY plan still counts — Default D8).
+ public bool PlanGenerated { get; set; }
+
+ /// Number of proposed buys in the plan (0 is a legitimate outcome — Default D8).
+ public int ProposedBuyCount { get; set; }
+
+ /// Total dollar amount across proposed buys.
+ public decimal TotalProposedAmount { get; set; }
+
+ ///
+ /// Paper orders actually placed. ALWAYS 0 when
+ /// IncomeSleeve:OrderPlacementEnabled is false (locked decision 1).
+ ///
+ public int OrdersPlaced { get; set; }
+
+ /// Whether the plan report send path completed without error (best-effort, Default D6).
+ public bool ReportSent { get; set; }
+
+ /// Non-fatal degradations (e.g. report delivery failure). The run still succeeds with these present.
+ public List Warnings { get; set; } = new();
+}
diff --git a/src/TradingSystem.Core/Interfaces/IIncomeReportService.cs b/src/TradingSystem.Core/Interfaces/IIncomeReportService.cs
new file mode 100644
index 0000000..057517d
--- /dev/null
+++ b/src/TradingSystem.Core/Interfaces/IIncomeReportService.cs
@@ -0,0 +1,26 @@
+using TradingSystem.Core.Models;
+
+namespace TradingSystem.Core.Interfaces;
+
+///
+/// Sends the monthly income reinvest plan report (S6-001, Default D6): a digest-class
+/// (neutral color) Discord message — NOT an orange operational alert. Sent on every
+/// non-skipped, connected reinvest run, including empty plans ("no buys proposed" is proof
+/// the timer ran — Default D8). Observability, never control: implementations must degrade
+/// to logs (no throw beyond caller cancellation) on delivery failure, and callers treat the
+/// send as best-effort — a report failure can never fail the run or influence any trading
+/// path. No secrets and no exception messages ever render in the report content.
+///
+public interface IIncomeReportService
+{
+ ///
+ /// Renders and sends the reinvestment plan report.
+ /// is the number of paper orders actually placed
+ /// (always 0 under the default recommendation-only posture — locked decision 1).
+ ///
+ Task SendReinvestmentPlanReportAsync(
+ ReinvestmentPlan plan,
+ IncomeSleeveState state,
+ int ordersPlaced,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/TradingSystem.Functions/DiscordIncomeReportService.cs b/src/TradingSystem.Functions/DiscordIncomeReportService.cs
new file mode 100644
index 0000000..904d20e
--- /dev/null
+++ b/src/TradingSystem.Functions/DiscordIncomeReportService.cs
@@ -0,0 +1,276 @@
+using System.Globalization;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using TradingSystem.Core.Configuration;
+using TradingSystem.Core.Interfaces;
+using TradingSystem.Core.Models;
+
+namespace TradingSystem.Functions;
+
+///
+/// S6-001 (Default D6): renders the monthly income reinvest plan as a digest-class NEUTRAL
+/// embed — deliberately NOT risk red or operational orange, because this message is routine
+/// evidence, not an alarm. Same webhook/config as the other Discord senders (no new secret)
+/// but its OWN named client ("DiscordIncomeReport", 8s timeout in Program.cs) so the senders'
+/// handlers/telemetry stay distinguishable. Reuses the S3-004 hardening verbatim via
+/// : https-only Discord host allow-list, token-redacted
+/// logging ({scheme}://{host} only), bounded 429/Retry-After retry with an injectable delay
+/// (zero-wait in tests), and the Enabled==false skip (one-time ctor Information notice,
+/// per-call Debug skip — B-006 pattern).
+///
+/// Content contract: the posture line (recommendation-only vs orders-placed — locked
+/// decision 1), the D4 available-cash input labeled as an estimate, sleeve total value,
+/// per-category drift, and the proposed buys (capped render). An empty plan renders an
+/// explicit "No buys proposed" line (Default D8 — proof the timer ran). No secrets and no
+/// exception messages ever appear in any embed or log. Delivery failure degrades to a loud
+/// AlertDropped=true log and returns — a missed report is never an operational stop.
+///
+public class DiscordIncomeReportService : IIncomeReportService
+{
+ // Retries are bounded by BOTH a retry count and a cumulative wait budget (S3-004 pattern).
+ private const int MaxRetries = 3;
+ private const double MaxTotalWaitSeconds = 10.0;
+
+ // Cap on rendered buy lines (Discord field limit is 1024 chars; the report is a digest).
+ private const int MaxRenderedBuys = 10;
+
+ // Digest-class neutral grey — never risk red (15158332) or ops orange (15105570).
+ private const int NeutralColor = 9807270;
+
+ private readonly HttpClient _httpClient;
+ private readonly DiscordConfig _config;
+ private readonly IncomeSleeveConfig _sleeveConfig;
+ private readonly ILogger _logger;
+ private readonly Func _delay;
+
+ public DiscordIncomeReportService(
+ IHttpClientFactory httpClientFactory,
+ IOptions config,
+ IOptions sleeveConfig,
+ ILogger logger)
+ : this(httpClientFactory, config, sleeveConfig, logger, Task.Delay)
+ {
+ }
+
+ // Internal ctor: the delay is injectable so tests run with zero real wait.
+ internal DiscordIncomeReportService(
+ IHttpClientFactory httpClientFactory,
+ IOptions config,
+ IOptions sleeveConfig,
+ ILogger logger,
+ Func delay)
+ {
+ _httpClient = httpClientFactory.CreateClient("DiscordIncomeReport");
+ _config = config.Value;
+ _sleeveConfig = sleeveConfig.Value;
+ _logger = logger;
+ _delay = delay;
+
+ if (!_config.Enabled)
+ {
+ // ONE-TIME Information-level notice at construction (B-006 pattern): without this
+ // the disabled state of the reinvest report path would be invisible at the
+ // Information minimum level; the per-call skip stays at Debug.
+ _logger.LogInformation(
+ "Discord income reinvest reports are disabled (Enabled=false); reinvest plan reports will NOT be delivered until enabled.");
+ }
+ }
+
+ public async Task SendReinvestmentPlanReportAsync(
+ ReinvestmentPlan plan,
+ IncomeSleeveState state,
+ int ordersPlaced,
+ CancellationToken cancellationToken = default)
+ {
+ var day = plan.PlanDate.Date;
+
+ if (!_config.Enabled)
+ {
+ _logger.LogDebug(
+ "Discord income reinvest reports are disabled; skipping report for {Date}", day);
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(_config.WebhookUrl))
+ {
+ _logger.LogWarning(
+ "Discord webhook URL is not configured; cannot send income reinvest report for {Date}", day);
+ return;
+ }
+
+ // Host allow-list + scheme check. On rejection only a redacted {scheme}://{host} (or
+ // an / sentinel) is logged — never the token-bearing path.
+ if (!DiscordWebhookGuard.TryValidateWebhook(_config.WebhookUrl, out var redacted))
+ {
+ _logger.LogWarning(
+ "Discord webhook URL is not an allowed https Discord endpoint ({Redacted}); skipping income reinvest report for {Date}",
+ redacted,
+ day);
+ return;
+ }
+
+ var payload = new
+ {
+ username = _config.Username,
+ // Disable mention parsing so report content can never ping @everyone/@here.
+ allowed_mentions = new { parse = Array.Empty() },
+ embeds = new[] { BuildPlanEmbed(plan, state, ordersPlaced) }
+ };
+
+ await PostWithRetryAsync(payload, day, cancellationToken);
+ }
+
+ private Embed BuildPlanEmbed(ReinvestmentPlan plan, IncomeSleeveState state, int ordersPlaced)
+ {
+ // Posture leads (locked decision 1): the owner's first read must answer "did this
+ // place orders?". The exact flag key renders so the runbook check is unambiguous.
+ var posture = _sleeveConfig.OrderPlacementEnabled
+ ? $"Order placement ENABLED — {ordersPlaced.ToString(CultureInfo.InvariantCulture)} paper order(s) placed (IncomeSleeve:OrderPlacementEnabled=true)."
+ : "Recommendation-only; IncomeSleeve:OrderPlacementEnabled=false. NO orders were placed.";
+
+ var fields = new List
+ {
+ new("Posture", posture, false),
+ // Default D4: the cash input is a heuristic estimate (TotalCashValue ×
+ // IncomeTargetPercent) — labeled so nobody mistakes it for dividend tracking.
+ new("Available Cash (estimate)",
+ $"{Money(plan.AvailableCash)} — TotalCashValue × IncomeTargetPercent (estimate; dividend/interest tracking not yet wired)",
+ true),
+ new("Sleeve Total Value", Money(state.TotalValue), true)
+ };
+
+ if (state.CategoryDrift.Count > 0)
+ {
+ var driftLines = state.CategoryDrift
+ .OrderBy(kv => kv.Value)
+ .Select(kv => $"{kv.Key}: {kv.Value.ToString("+0.0%;-0.0%;0.0%", CultureInfo.InvariantCulture)}");
+ fields.Add(new EmbedField("Category Drift (current − target)",
+ string.Join(Environment.NewLine, driftLines), false));
+ }
+
+ if (plan.ProposedBuys.Count == 0)
+ {
+ // Default D8: an empty plan is a legitimate outcome (e.g. an unfunded sleeve) —
+ // say so explicitly; this message existing at all is proof the timer ran.
+ fields.Add(new EmbedField("Proposed Buys", "No buys proposed.", false));
+ }
+ else
+ {
+ var lines = plan.ProposedBuys
+ .Take(MaxRenderedBuys)
+ .Select(b => string.Create(
+ CultureInfo.InvariantCulture,
+ $"{b.Symbol} ({b.Category}): {Money(b.Amount)} — {b.Rationale}"));
+ var overflow = plan.ProposedBuys.Count > MaxRenderedBuys
+ ? $"{Environment.NewLine}... and {(plan.ProposedBuys.Count - MaxRenderedBuys).ToString(CultureInfo.InvariantCulture)} more"
+ : string.Empty;
+ fields.Add(new EmbedField("Proposed Buys",
+ string.Join(Environment.NewLine, lines) + overflow, false));
+ }
+
+ var description = plan.ProposedBuys.Count == 0
+ ? "No buys proposed — sleeve is unfunded or fully on-target."
+ : $"{plan.ProposedBuys.Count.ToString(CultureInfo.InvariantCulture)} proposed buy(s) totaling {Money(plan.TotalProposedAmount)}.";
+
+ return new Embed(
+ title: $"Income Reinvest Plan — {plan.PlanDate.ToString("ddd MMM d, yyyy", CultureInfo.InvariantCulture)}",
+ description: description,
+ color: NeutralColor,
+ timestamp: plan.PlanDate.Date.ToString("O", CultureInfo.InvariantCulture),
+ fields: fields.ToArray());
+ }
+
+ // POSTs the payload, honoring a 429 Retry-After with a bounded backoff (count + cumulative
+ // wait budget). Degrades to a loud AlertDropped=true log and returns (no throw) on non-2xx
+ // exhaustion, timeout, or transport failure. Status code / exception TYPE only is logged —
+ // never the webhook URL, token, or an exception message (which can echo the request URI).
+ private async Task PostWithRetryAsync(object payload, DateTime day, CancellationToken cancellationToken)
+ {
+ var start = DateTime.UtcNow;
+ var attempt = 0;
+ while (true)
+ {
+ try
+ {
+ using var response = await _httpClient.PostAsJsonAsync(_config.WebhookUrl, payload, cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ _logger.LogInformation("Sent Discord income reinvest report for {Date}", day);
+ return;
+ }
+
+ var remaining = MaxTotalWaitSeconds - (DateTime.UtcNow - start).TotalSeconds;
+ if ((int)response.StatusCode != 429 || attempt >= MaxRetries || remaining <= 0)
+ {
+ // Terminal failure: the report is gone and will NOT be retried — make it
+ // loud and log-searchable (AlertDropped=true) so the 2026-07-01 run-record
+ // check ("did the plan report arrive?") has a definitive negative signal.
+ _logger.LogError(
+ "Income reinvest report NOT delivered and will not be retried. AlertDropped={AlertDropped}, StatusCode={StatusCode}, Attempts={Attempt}/{MaxRetries}, Date={Date}",
+ true,
+ (int)response.StatusCode,
+ attempt + 1,
+ MaxRetries,
+ day);
+ return;
+ }
+
+ var wait = Math.Max(0, Math.Min(DiscordWebhookGuard.RetryAfterSeconds(response), remaining));
+ _logger.LogWarning(
+ "Discord rate-limited (429); retrying income reinvest report for {Date} after {WaitSeconds:F2}s (attempt {Attempt}/{Max})",
+ day,
+ wait,
+ attempt + 1,
+ MaxRetries);
+ await _delay(TimeSpan.FromSeconds(wait), cancellationToken);
+ attempt++;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Caller cancelled — propagate cancellation, it carries no token.
+ throw;
+ }
+ catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ // HttpClient timeout (the 8s named-client bound): the report never reached
+ // Discord and will NOT be retried. Type names only.
+ _logger.LogError(
+ "Income reinvest report NOT delivered and will not be retried — send timed out. AlertDropped={AlertDropped}, ErrorType={ErrorType}, InnerErrorType={InnerErrorType}, Date={Date}",
+ true,
+ ex.GetType().Name,
+ ex.InnerException?.GetType().Name,
+ day);
+ return;
+ }
+ catch (HttpRequestException ex)
+ {
+ // Transport failure. NEVER log ex.Message / ex.ToString() — those can echo
+ // the token-bearing request URI.
+ _logger.LogError(
+ "Income reinvest report NOT delivered and will not be retried — transport error. AlertDropped={AlertDropped}, ErrorType={ErrorType}, StatusCode={StatusCode}, InnerErrorType={InnerErrorType}, Date={Date}",
+ true,
+ ex.GetType().Name,
+ ex.StatusCode,
+ ex.InnerException?.GetType().Name,
+ day);
+ return;
+ }
+ }
+ }
+
+ private static string Money(decimal value) =>
+ value < 0
+ ? string.Create(CultureInfo.InvariantCulture, $"-${Math.Abs(value):N2}")
+ : string.Create(CultureInfo.InvariantCulture, $"${value:N2}");
+
+ // Discord embed shapes (lowercase property names match the webhook JSON contract).
+ private sealed record Embed(
+ string title,
+ string description,
+ int color,
+ string timestamp,
+ EmbedField[] fields);
+
+ private sealed record EmbedField(string name, string value, bool inline);
+}
diff --git a/src/TradingSystem.Functions/DiscordRiskAlertService.cs b/src/TradingSystem.Functions/DiscordRiskAlertService.cs
index 68a2a58..c2514de 100644
--- a/src/TradingSystem.Functions/DiscordRiskAlertService.cs
+++ b/src/TradingSystem.Functions/DiscordRiskAlertService.cs
@@ -39,9 +39,11 @@ public class DiscordRiskAlertService : IRiskAlertService, IOperationalAlertServi
// Terminal-drop guidance per alert kind. The risk wording is unchanged (a missed stop alert
// demands manual verification); the operational wording is softer — an ops alert is not a
- // capital-preservation stop, the run outcome itself is already in logs/App Insights.
+ // capital-preservation stop, the run outcome itself is already in the worker logs.
+ // Application Insights is OFF by design (KD-006), so triage points at the local logs.
private const string RiskDroppedNote = "verify the risk stop was acted on manually";
- private const string OperationalDroppedNote = "check the run outcome in logs/Application Insights";
+ private const string OperationalDroppedNote =
+ @"check the run outcome in the worker console or %LOCALAPPDATA%\TradingSystem\logs";
private readonly HttpClient _httpClient;
private readonly DiscordConfig _config;
diff --git a/src/TradingSystem.Functions/IncomeReinvestService.cs b/src/TradingSystem.Functions/IncomeReinvestService.cs
new file mode 100644
index 0000000..4472fde
--- /dev/null
+++ b/src/TradingSystem.Functions/IncomeReinvestService.cs
@@ -0,0 +1,260 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using TradingSystem.Core.Configuration;
+using TradingSystem.Core.Interfaces;
+using TradingSystem.Core.Models;
+using TradingSystem.Strategies.Income;
+
+namespace TradingSystem.Functions;
+
+///
+/// S6-001 monthly reinvest pipeline (Default D2 — the S5-001 thin-timer shape):
+/// first-trading-weekday gate (Default D3, in code — NCRONTAB day-of-month/day-of-week
+/// semantics are never trusted) → broker connect (failure = S5-003 operational alert +
+/// structured degrade, Default D7) → recommendation plan through the EXISTING
+/// (10% issuer / 40% category caps enforced at plan time —
+/// reused, never duplicated) → flag-gated paper order placement
+/// (, default FALSE — locked decision 1)
+/// → best-effort Discord plan report (Default D6; report failure can never fail the run) →
+/// disconnect in finally.
+///
+/// Recommendation-cash input (Default D4): availableCash = Account.TotalCashValue ×
+/// TradingSystemConfig.IncomeTargetPercent (the existing MonthlyReinvestStrategy heuristic).
+/// There is no dividend-activity data source yet, so ReinvestmentPlan.DividendsReceived /
+/// InterestReceived stay 0 — fields are left default-valued rather than guessed (S5-001
+/// fidelity rule). An empty sleeve legitimately yields an empty plan and the report still
+/// goes out saying no buys are proposed (Default D8) — no bootstrap/seeding logic here
+/// (that is a human capital-allocation action, ADR-021).
+///
+public class IncomeReinvestService : IIncomeReinvestService
+{
+ private readonly IBrokerService _broker;
+ private readonly IncomeSleeveManager _sleeveManager;
+ private readonly TradingSystemConfig _config;
+ private readonly IncomeSleeveConfig _sleeveConfig;
+ private readonly ILogger _logger;
+ private readonly IIncomeReportService? _reportService;
+ private readonly IOperationalAlertService? _operationalAlertService;
+
+ // S5-003 alert-spam guard (Default D7): once per run per failure category, keyed
+ // runId:category — same pattern as EndOfDayService/DailyOrchestrator. The timer fires
+ // once a month with a fresh runId, so this stays tiny over a long-lived singleton.
+ private readonly object _alertGateLock = new();
+ private readonly HashSet _alertedRunCategories = new(StringComparer.Ordinal);
+
+ public IncomeReinvestService(
+ IBrokerService broker,
+ IncomeSleeveManager sleeveManager,
+ IOptions config,
+ IOptions sleeveConfig,
+ ILogger logger,
+ IIncomeReportService? reportService = null,
+ IOperationalAlertService? operationalAlertService = null)
+ {
+ _broker = broker;
+ _sleeveManager = sleeveManager;
+ _config = config.Value;
+ _sleeveConfig = sleeveConfig.Value;
+ _logger = logger;
+ _reportService = reportService;
+ _operationalAlertService = operationalAlertService;
+ }
+
+ public async Task RunAsync(
+ string runId, DateTime utcNow, CancellationToken cancellationToken = default)
+ {
+ var result = new IncomeReinvestResult();
+
+ // Default D3: the AUTHORITATIVE schedule filter. The cron restricts day-of-month AND
+ // day-of-week, which (depending on NCRONTAB union/intersection semantics) can fire on
+ // every weekday of the first week — only the computed first trading weekday proceeds.
+ // (Holiday edge accepted: a market-holiday gate day degrades like any closed-market
+ // day — the recommendation-only output is benign. Documented in the runbook, S6-003.)
+ var gateDay = FirstTradingWeekday(utcNow);
+ if (utcNow.Date != gateDay)
+ {
+ result.Skipped = true;
+ result.SkipReason =
+ $"Not the first trading weekday of the month (gate day {gateDay:yyyy-MM-dd}, fired {utcNow.Date:yyyy-MM-dd}).";
+ _logger.LogInformation(
+ "Monthly reinvest skipped — {SkipReason} RunId: {RunId}",
+ result.SkipReason,
+ runId);
+ return result;
+ }
+
+ var connected = await _broker.ConnectAsync(cancellationToken);
+ if (!connected)
+ {
+ // Default D7 (S5-003 verbatim): no plan, no report, no throw — best-effort
+ // operational alert, once per run per category.
+ _logger.LogWarning(
+ "Could not connect to broker for monthly reinvest. No plan will be generated. RunId: {RunId}. " +
+ "Verify TWS/IB Gateway is running and the API port/client-id match config.",
+ runId);
+
+ await TrySendOperationalAlertAsync(
+ runId,
+ "connect-failure",
+ "Broker Connect Failure — Monthly Reinvest",
+ $"Could not connect to the broker for the monthly reinvest run. No plan was generated and no report was sent. RunId: {runId}. " +
+ "Verify TWS/IB Gateway is running and the API port/client-id match config.",
+ cancellationToken);
+
+ return result;
+ }
+
+ result.BrokerConnected = true;
+ try
+ {
+ // Default D4: recommendation-cash input — the existing MonthlyReinvestStrategy
+ // heuristic, recorded in the plan (and rendered in the report) so the owner sees it.
+ var account = await _broker.GetAccountAsync(cancellationToken);
+ var availableCash = account.TotalCashValue * _config.IncomeTargetPercent;
+ _logger.LogInformation(
+ "Monthly reinvest cash input (estimate): {AvailableCash:C} = TotalCashValue {TotalCash:C} × IncomeTargetPercent {Target:P0}. RunId: {RunId}",
+ availableCash,
+ account.TotalCashValue,
+ _config.IncomeTargetPercent,
+ runId);
+
+ // Recommendation path — ALWAYS. Caps (10% issuer / 40% category, post-buy
+ // semantics) are enforced inside the existing plan generator; never duplicated here.
+ var state = await _sleeveManager.GetSleeveStateAsync(cancellationToken);
+ var plan = _sleeveManager.GenerateReinvestmentPlan(state, availableCash, cancellationToken);
+ result.PlanGenerated = true;
+ result.ProposedBuyCount = plan.ProposedBuys.Count;
+ result.TotalProposedAmount = plan.TotalProposedAmount;
+
+ if (_sleeveConfig.OrderPlacementEnabled)
+ {
+ // Owner-gated paper order path: the EXISTING IncomeSleeveManager execution
+ // pipeline (live quotes → limit orders via IExecutionService). No new
+ // execution logic, no mode logic.
+ // Order submissions are SEQUENTIAL by design: IBKR serializes order
+ // submission per client id, and the income universe is small. Do not
+ // parallelize without revisiting IBKRRequestManager semantics.
+ var executions = await _sleeveManager.ExecuteReinvestmentPlanAsync(plan, cancellationToken);
+ result.OrdersPlaced = executions.Count(e => e.Success);
+ _logger.LogInformation(
+ "Monthly reinvest posture: order placement ENABLED (IncomeSleeve:OrderPlacementEnabled=true). " +
+ "OrdersPlaced: {OrdersPlaced}/{Proposed}. RunId: {RunId}",
+ result.OrdersPlaced,
+ result.ProposedBuyCount,
+ runId);
+ }
+ else
+ {
+ // LOCKED DECISION 1: the default posture places NO orders — state it in the
+ // run record so the (expected) absence of TWS orders is auditable.
+ _logger.LogInformation(
+ "Monthly reinvest posture: recommendation-only; IncomeSleeve:OrderPlacementEnabled=false. " +
+ "No orders will be placed. RunId: {RunId}",
+ runId);
+ }
+
+ // Default D6/D8: best-effort report on every non-skipped, connected run —
+ // including empty plans ("no buys proposed" is proof the timer ran). Report
+ // failure degrades to a warning and can never fail the run.
+ await TrySendReportAsync(runId, plan, state, result, cancellationToken);
+
+ return result;
+ }
+ finally
+ {
+ await _broker.DisconnectAsync();
+ }
+ }
+
+ ///
+ /// First Mon–Fri day of 's month — the gate day (Default D3).
+ ///
+ internal static DateTime FirstTradingWeekday(DateTime utcNow)
+ {
+ var day = new DateTime(utcNow.Year, utcNow.Month, 1);
+ while (day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
+ {
+ day = day.AddDays(1);
+ }
+ return day;
+ }
+
+ private async Task TrySendReportAsync(
+ string runId,
+ ReinvestmentPlan plan,
+ IncomeSleeveState state,
+ IncomeReinvestResult result,
+ CancellationToken cancellationToken)
+ {
+ if (_reportService == null)
+ {
+ _logger.LogDebug(
+ "IIncomeReportService not registered; reinvest plan report skipped. RunId: {RunId}",
+ runId);
+ return;
+ }
+
+ try
+ {
+ await _reportService.SendReinvestmentPlanReportAsync(plan, state, result.OrdersPlaced, cancellationToken);
+ result.ReportSent = true;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Observability is never control: exception TYPE NAME only (messages can echo
+ // the token-bearing webhook URI), warning + structured Warnings entry, no throw.
+ _logger.LogWarning(
+ "Income reinvest plan report failed ({ErrorType}); run outcome unaffected. RunId: {RunId}",
+ ex.GetType().Name,
+ runId);
+ result.Warnings.Add($"Income reinvest report failed ({ex.GetType().Name}).");
+ }
+ }
+
+ ///
+ /// S5-003 best-effort operational alert: null-tolerant, never throws (alert failure must
+ /// never fail or replace the run's outcome), once per run per failure category.
+ ///
+ private async Task TrySendOperationalAlertAsync(
+ string runId,
+ string category,
+ string title,
+ string description,
+ CancellationToken cancellationToken)
+ {
+ if (_operationalAlertService == null)
+ return;
+
+ bool claimed;
+ lock (_alertGateLock)
+ {
+ claimed = _alertedRunCategories.Add($"{runId}:{category}");
+ }
+
+ if (!claimed)
+ {
+ _logger.LogDebug(
+ "Operational alert already sent for this run/category; skipping. RunId: {RunId}, Category: {Category}",
+ runId,
+ category);
+ return;
+ }
+
+ try
+ {
+ await _operationalAlertService.SendOperationalAlertAsync(title, description, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(
+ "Operational alert send failed ({ErrorType}); reinvest outcome unaffected. RunId: {RunId}, Category: {Category}",
+ ex.GetType().Name,
+ runId,
+ category);
+ }
+ }
+}
diff --git a/src/TradingSystem.Functions/IncomeSleeveFunction.cs b/src/TradingSystem.Functions/IncomeSleeveFunction.cs
index 4865c69..bfda1d8 100644
--- a/src/TradingSystem.Functions/IncomeSleeveFunction.cs
+++ b/src/TradingSystem.Functions/IncomeSleeveFunction.cs
@@ -1,7 +1,9 @@
using Microsoft.Azure.Functions.Worker;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TradingSystem.Core.Configuration;
+using TradingSystem.Core.Interfaces;
namespace TradingSystem.Functions;
@@ -12,12 +14,20 @@ public class IncomeSleeveFunction
{
private readonly ILogger _logger;
private readonly TradingSystemConfig _config;
+ private readonly IServiceProvider _serviceProvider;
private readonly Func _utcNow;
+ // S5-003 alert-spam guard (Default D7): once per run per failure category, keyed
+ // runId:category — same pattern as DailyOrchestrator. The reinvest timer fires once a
+ // month at most, so this stays tiny over a long-lived instance.
+ private readonly object _alertGateLock = new();
+ private readonly HashSet _alertedRunCategories = new(StringComparer.Ordinal);
+
public IncomeSleeveFunction(
ILogger logger,
- IOptions config)
- : this(logger, config, () => DateTime.UtcNow)
+ IOptions config,
+ IServiceProvider serviceProvider)
+ : this(logger, config, serviceProvider, () => DateTime.UtcNow)
{
}
@@ -28,48 +38,135 @@ public IncomeSleeveFunction(
internal IncomeSleeveFunction(
ILogger logger,
IOptions config,
+ IServiceProvider serviceProvider,
Func utcNow)
{
_logger = logger;
_config = config.Value;
+ _serviceProvider = serviceProvider;
_utcNow = utcNow;
}
///
- /// Monthly reinvest - First trading day of month at 6:30 AM PT
+ /// Monthly reinvest - First trading day of month at 6:30 AM PT.
+ /// S6-001 thin timer wrapper (S5-001 / DailyOrchestrator EOD pattern): the pipeline lives
+ /// in . Default posture (locked decision 1):
+ /// recommendation-only — plan + Discord report, NO orders, unless the owner has flipped
+ /// IncomeSleeve:OrderPlacementEnabled to true.
///
[Function("IncomeSleeve_MonthlyReinvest")]
public async Task RunMonthlyReinvest(
[TimerTrigger("0 30 13 1-7 * 1-5")] TimerInfo timer, // First Mon-Fri, days 1-7
CancellationToken cancellationToken)
{
- // Only run on the first weekday of the month
+ // Cheap pre-filter, belt-and-braces only: the AUTHORITATIVE first-trading-weekday
+ // gate lives in IncomeReinvestService (Default D3) — NCRONTAB day-of-month/day-of-week
+ // union-vs-intersection semantics are never trusted to do this filtering.
if (_utcNow().Day > 7) return;
-
+
var runId = Guid.NewGuid().ToString("N")[..8];
_logger.LogInformation("Starting monthly income reinvest. RunId: {RunId}", runId);
try
{
- // TODO: Implement
- // 1. Pull sleeve positions and cash
- // 2. Get dividends/interest received this month
- // 3. Compute current weights vs targets
- // 4. Screen income universe with quality gates
- // 5. Build buy list to reduce drift
- // 6. Execute with limit orders
- // 7. Verify caps respected
- // 8. Log rebalance summary
-
- _logger.LogInformation("Monthly income reinvest complete. RunId: {RunId}", runId);
+ // Null-tolerant resolve (ADR-024): a missing registration degrades, never crashes.
+ var reinvestService = _serviceProvider.GetService();
+ if (reinvestService == null)
+ {
+ _logger.LogWarning(
+ "IIncomeReinvestService not registered. Skipping monthly reinvest. RunId: {RunId}",
+ runId);
+ return;
+ }
+
+ var result = await reinvestService.RunAsync(runId, _utcNow(), cancellationToken);
+
+ if (result.Warnings.Count > 0)
+ {
+ _logger.LogWarning(
+ "Monthly reinvest warnings. RunId: {RunId}. {Warnings}",
+ runId,
+ string.Join(" | ", result.Warnings));
+ }
+
+ _logger.LogInformation(
+ "Monthly income reinvest finished. RunId: {RunId}, Skipped: {Skipped}, BrokerConnected: {BrokerConnected}, PlanGenerated: {PlanGenerated}, ProposedBuyCount: {ProposedBuyCount}, TotalProposedAmount: {TotalProposedAmount:C}, OrdersPlaced: {OrdersPlaced}, ReportSent: {ReportSent}, SkipReason: {SkipReason}",
+ runId,
+ result.Skipped,
+ result.BrokerConnected,
+ result.PlanGenerated,
+ result.ProposedBuyCount,
+ result.TotalProposedAmount,
+ result.OrdersPlaced,
+ result.ReportSent,
+ result.SkipReason ?? string.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Monthly income reinvest failed. RunId: {RunId}", runId);
+
+ // Default D7 (S5-003): operational orchestration-failure alert BEFORE the rethrow.
+ // Exception TYPE NAME only — messages can echo URIs/secrets. CancellationToken.None:
+ // a cancelled run must still be able to alert.
+ await TrySendOperationalAlertAsync(
+ runId,
+ "orchestration-failure",
+ "Orchestration Run Failure — Monthly Reinvest",
+ $"Unhandled {ex.GetType().Name} during the monthly reinvest run. RunId: {runId}. " +
+ "The reinvest plan/report may not have been produced. See the worker logs for details.",
+ CancellationToken.None);
+
throw;
}
}
+ ///
+ /// S5-003 best-effort operational alert (DailyOrchestrator pattern): null-tolerant
+ /// resolve, never throws (alert failure must never mask or replace the run's outcome),
+ /// gated to once per run per failure category. Exception TYPE NAMES only ever reach
+ /// alerts/logs here — never messages, which can echo URIs/secrets.
+ ///
+ private async Task TrySendOperationalAlertAsync(
+ string runId,
+ string category,
+ string title,
+ string description,
+ CancellationToken cancellationToken)
+ {
+ var operationalAlerts = _serviceProvider.GetService();
+ if (operationalAlerts == null)
+ return;
+
+ bool claimed;
+ lock (_alertGateLock)
+ {
+ claimed = _alertedRunCategories.Add($"{runId}:{category}");
+ }
+
+ if (!claimed)
+ {
+ _logger.LogDebug(
+ "Operational alert already sent for this run/category; skipping. RunId: {RunId}, Category: {Category}",
+ runId,
+ category);
+ return;
+ }
+
+ try
+ {
+ await operationalAlerts.SendOperationalAlertAsync(title, description, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ // Observability must never become control: swallow and log (type name only).
+ _logger.LogWarning(
+ "Operational alert send failed ({ErrorType}); reinvest outcome unaffected. RunId: {RunId}, Category: {Category}",
+ ex.GetType().Name,
+ runId,
+ category);
+ }
+ }
+
///
/// Quarterly quality audit - First week of Jan, Apr, Jul, Oct
///
diff --git a/src/TradingSystem.Functions/Program.cs b/src/TradingSystem.Functions/Program.cs
index f84f349..7c0bffb 100644
--- a/src/TradingSystem.Functions/Program.cs
+++ b/src/TradingSystem.Functions/Program.cs
@@ -58,6 +58,12 @@
// decision 5). Operational/observability config only — never risk parameters.
services.Configure(
context.Configuration.GetSection("Reporting"));
+ // S6-001 (Default D5): owner gate for the monthly reinvest timer. OrderPlacementEnabled
+ // defaults FALSE (locked decision 1: recommendation-only — plan + report, NO orders).
+ // Operational gate config, NOT risk config; injected as IOptions
+ // only — deliberately no bare-type registration of this config.
+ services.Configure(
+ context.Configuration.GetSection("IncomeSleeve"));
// Application Insights — registration is inert until APPLICATIONINSIGHTS_CONNECTION_STRING
// is set (it is NOT set in local.settings.json or any deployed config today): with no
@@ -123,6 +129,27 @@
services.AddSingleton();
services.AddSingleton();
+ // S6-001 income sleeve wiring. IncomeSleeveManager's ctor takes BARE
+ // IncomeConfig/ExecutionConfig/IncomeUniverse — bare-type resolution is exactly the
+ // S5-004r boot-crash class (DI validation kills the worker at startup if any of these
+ // is unresolvable, and the unit suite never builds the full host graph). The factory
+ // delegates below make them resolvable from the one bound TradingSystemConfig; the
+ // host-boot smoke (tools/ci/host-boot-smoke.sh, S6-002) is the gate that proves it.
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ sp.GetRequiredService>().Value.Income);
+ services.AddSingleton(sp =>
+ sp.GetRequiredService>().Value.Execution);
+ services.AddSingleton();
+ // Thin-timer pipeline (Default D2) + digest-class plan report (Default D6). The report
+ // reuses the SAME webhook/config as the other Discord senders (no new secret) but gets
+ // its own named client so the senders stay distinguishable. 8s send bound: a report
+ // POST must never hang the reinvest run (constraint 4 — timeout surfaces as
+ // OperationCanceledException and is swallowed by the report's no-throw contract).
+ services.AddHttpClient("DiscordIncomeReport", c => c.Timeout = TimeSpan.FromSeconds(8));
+ services.AddSingleton();
+ services.AddSingleton();
+
// AI Service — Claude regime detection with rule-based fallback (ADR-003, ADR-012).
// Registration (config bind + gated client wiring, incl. the named ClaudeGateway client)
// is centralized in ClaudeServiceRegistration so it is unit-testable (S2-004, ADR-029).
diff --git a/src/TradingSystem.Functions/local.settings.json.example b/src/TradingSystem.Functions/local.settings.json.example
index 31315c9..9f2f3ed 100644
--- a/src/TradingSystem.Functions/local.settings.json.example
+++ b/src/TradingSystem.Functions/local.settings.json.example
@@ -30,6 +30,9 @@
"Reporting:WeeklyScorecardDay": "Friday",
+ "_comment_IncomeSleeve_OrderPlacementEnabled": "OWNER-GATED (S6-001, locked decision 1). Default false = recommendation-only: the monthly reinvest timer computes the plan and sends the Discord report but places NO orders. Flipping to true enables PAPER limit orders on the reinvest timer via the existing execution path. The income sleeve's PDR-004 >=12-week clock starts at its first actual trade, not at plan generation.",
+ "IncomeSleeve:OrderPlacementEnabled": "false",
+
"_comment_Claude_ApiKey": "Metered Anthropic API fallback key. NOT needed under the default posture (DirectApiFallbackEnabled=false) — leave the placeholder as-is.",
"Claude:ApiKey": "PLACEHOLDER_NOT_NEEDED_WHILE_FALLBACK_DISABLED",
"_comment_Claude_GatewayApiKey": "Bearer token for the local subscription claude-gateway (localhost:3131). Retrieve from the Bitwarden 'ClimbOn Co' org vault.",
diff --git a/tests/TradingSystem.Tests/Functions/DiscordIncomeReportServiceTests.cs b/tests/TradingSystem.Tests/Functions/DiscordIncomeReportServiceTests.cs
new file mode 100644
index 0000000..7105fcd
--- /dev/null
+++ b/tests/TradingSystem.Tests/Functions/DiscordIncomeReportServiceTests.cs
@@ -0,0 +1,331 @@
+using System.Net;
+using System.Net.Http;
+using Microsoft.Extensions.Logging;
+using Moq;
+using TradingSystem.Core.Configuration;
+using TradingSystem.Core.Models;
+using TradingSystem.Functions;
+using Xunit;
+using MsOptions = Microsoft.Extensions.Options.Options;
+
+namespace TradingSystem.Tests.Functions;
+
+///
+/// S6-001 (Default D6): DiscordIncomeReportService renders the monthly reinvest plan as a
+/// digest-class NEUTRAL embed (never risk red / ops orange) on its own named client
+/// ("DiscordIncomeReport", 8s timeout in Program.cs), reusing the S3-004 hardening verbatim:
+/// https-only Discord host allow-list, token-redacted logging, bounded 429 retry with an
+/// injectable zero-wait delay, Enabled==false skip (one-time ctor Information notice,
+/// per-call Debug skip), and the loud AlertDropped=true terminal log. ALL HTTP is mocked via
+/// a controllable handler — no live POST is ever made. Content assertions lock the locked
+/// posture line (recommendation-only by default), the D4 cash input, and the Default D8
+/// "no buys proposed" honesty line. No secret and no exception message ever reaches the
+/// payload or a log.
+///
+public class DiscordIncomeReportServiceTests
+{
+ private const string SecretToken = "incomesecrettoken";
+
+ private static readonly DateTime PlanDate = new(2026, 7, 1, 13, 30, 0, DateTimeKind.Utc);
+
+ // ---------- HTTP stub (same pattern as the daily-report tests; duplicated so this test
+ // file stays independently runnable — no cross-test-file imports) ----------
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ public int InvocationCount { get; private set; }
+ public List Bodies { get; } = new();
+ private readonly Queue> _responders;
+ private Func? _last;
+
+ private StubHandler(IEnumerable> responders)
+ {
+ _responders = new Queue>(responders);
+ }
+
+ public static StubHandler ReturnsStatuses(params HttpStatusCode[] codes)
+ => new(codes.Select>(c => () => new HttpResponseMessage(c)));
+
+ public static StubHandler ReturnsResponses(params Func[] factories)
+ => new(factories);
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ InvocationCount++;
+ if (request.Content != null)
+ Bodies.Add(await request.Content.ReadAsStringAsync(cancellationToken));
+
+ if (_responders.Count > 0)
+ _last = _responders.Dequeue();
+ return (_last ?? (() => new HttpResponseMessage(HttpStatusCode.NoContent)))();
+ }
+ }
+
+ private sealed class FakeHttpClientFactory : IHttpClientFactory
+ {
+ private readonly HttpMessageHandler _handler;
+ public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
+ public HttpClient CreateClient(string name) => new(_handler);
+ }
+
+ // ---------- fixtures ----------
+
+ private static DiscordConfig Config(bool enabled = true, string? url = null) => new()
+ {
+ Enabled = enabled,
+ WebhookUrl = url ?? "https://discord.com/api/webhooks/456/" + SecretToken,
+ Username = "TradingSystem Risk"
+ };
+
+ private static (DiscordIncomeReportService Service, StubHandler Handler, Mock> Logger)
+ Build(DiscordConfig? config = null, StubHandler? handler = null, bool orderPlacementEnabled = false)
+ {
+ handler ??= StubHandler.ReturnsStatuses(HttpStatusCode.NoContent);
+ var logger = new Mock>();
+ var service = new DiscordIncomeReportService(
+ new FakeHttpClientFactory(handler),
+ MsOptions.Create(config ?? Config()),
+ MsOptions.Create(new IncomeSleeveConfig { OrderPlacementEnabled = orderPlacementEnabled }),
+ logger.Object,
+ (_, _) => Task.CompletedTask); // zero-wait injectable delay
+ return (service, handler, logger);
+ }
+
+ private static ReinvestmentPlan Plan(int buys = 2, decimal amountEach = 5_000m)
+ {
+ var plan = new ReinvestmentPlan
+ {
+ PlanDate = PlanDate,
+ AvailableCash = 70_000m
+ };
+ for (var i = 0; i < buys; i++)
+ {
+ plan.ProposedBuys.Add(new ReinvestmentOrder
+ {
+ Symbol = $"SYM{i}",
+ Category = IncomeCategory.EquityREIT,
+ Amount = amountEach,
+ Rationale = $"Reduce EquityREIT drift of -{i + 1}.0%"
+ });
+ }
+ return plan;
+ }
+
+ private static IncomeSleeveState State() => new()
+ {
+ TotalValue = 50_000m,
+ CategoryDrift = new Dictionary
+ {
+ { IncomeCategory.DividendGrowthETF, 0.75m },
+ { IncomeCategory.EquityREIT, -0.10m }
+ }
+ };
+
+ private static List<(LogLevel Level, string Message)> CapturedLogs(Mock> logger)
+ {
+ var entries = new List<(LogLevel, string)>();
+ foreach (var inv in logger.Invocations)
+ {
+ if (inv.Method.Name != nameof(ILogger.Log))
+ continue;
+ entries.Add(((LogLevel)inv.Arguments[0], inv.Arguments[2]?.ToString() ?? string.Empty));
+ }
+ return entries;
+ }
+
+ // ---------- content: title, posture, D4 cash, neutral color ----------
+
+ [Fact]
+ public async Task Send_PostsSingleEmbed_WithTitlePostureCashAndSleeveTotal()
+ {
+ var (service, handler, _) = Build();
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ var raw = Assert.Single(handler.Bodies);
+ // System.Text.Json escapes non-ASCII (the em dash renders as —) — unescape so
+ // the assertion locks the exact human-visible title the runbook documents.
+ var body = System.Text.RegularExpressions.Regex.Unescape(raw);
+ Assert.Contains("Income Reinvest Plan —", body);
+ // Locked decision 1: the default posture is stated, with the exact flag key.
+ Assert.Contains("recommendation-only", body, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("IncomeSleeve:OrderPlacementEnabled=false", body);
+ // D4 cash input, labeled as an estimate.
+ Assert.Contains("70,000.00", body);
+ Assert.Contains("estimate", body, StringComparison.OrdinalIgnoreCase);
+ // Sleeve total value renders for context.
+ Assert.Contains("50,000.00", body);
+ // Mention parsing disabled.
+ Assert.Contains("\"allowed_mentions\"", body);
+ }
+
+ [Fact]
+ public async Task Send_UsesNeutralDigestColor_NotRiskRedOrOpsOrange()
+ {
+ var (service, handler, _) = Build();
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ var body = Assert.Single(handler.Bodies);
+ // Digest-class neutral grey — never risk red (15158332) or ops orange (15105570).
+ Assert.Contains("\"color\":9807270", body);
+ Assert.DoesNotContain("15158332", body);
+ Assert.DoesNotContain("15105570", body);
+ }
+
+ [Fact]
+ public async Task Send_RendersProposedBuys_WithSymbolCategoryAmountAndRationale()
+ {
+ var (service, handler, _) = Build();
+
+ await service.SendReinvestmentPlanReportAsync(Plan(buys: 2), State(), ordersPlaced: 0);
+
+ var body = Assert.Single(handler.Bodies);
+ Assert.Contains("SYM0", body);
+ Assert.Contains("SYM1", body);
+ Assert.Contains("EquityREIT", body);
+ Assert.Contains("5,000.00", body);
+ Assert.Contains("Reduce EquityREIT drift", body);
+ }
+
+ [Fact]
+ public async Task Send_CapsRenderedBuys_WithOverflowLine()
+ {
+ var (service, handler, _) = Build();
+
+ await service.SendReinvestmentPlanReportAsync(Plan(buys: 12), State(), ordersPlaced: 0);
+
+ var body = Assert.Single(handler.Bodies);
+ Assert.Contains("SYM9", body); // 10th buy renders
+ Assert.DoesNotContain("SYM10", body); // 11th does not
+ Assert.Contains("2 more", body); // overflow note
+ }
+
+ [Fact]
+ public async Task Send_EmptyPlan_SaysNoBuysProposed()
+ {
+ var (service, handler, _) = Build();
+
+ await service.SendReinvestmentPlanReportAsync(Plan(buys: 0), State(), ordersPlaced: 0);
+
+ var body = Assert.Single(handler.Bodies);
+ // Default D8: the empty-sleeve report is proof the timer ran.
+ Assert.Contains("No buys proposed", body, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Send_FlagOnPosture_StatesOrdersPlacedCount()
+ {
+ var (service, handler, _) = Build(orderPlacementEnabled: true);
+
+ await service.SendReinvestmentPlanReportAsync(Plan(buys: 3), State(), ordersPlaced: 3);
+
+ var body = Assert.Single(handler.Bodies);
+ Assert.DoesNotContain("recommendation-only", body, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("3", body);
+ Assert.Contains("order", body, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ---------- enabled/config guards ----------
+
+ [Fact]
+ public async Task Send_Disabled_MakesZeroPosts_AndSkipsAtDebug()
+ {
+ var (service, handler, logger) = Build(Config(enabled: false));
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ Assert.Equal(0, handler.InvocationCount);
+ // One-time ctor Information notice + per-call Debug skip (B-006 pattern).
+ Assert.Contains(CapturedLogs(logger), e => e.Level == LogLevel.Information && e.Message.Contains("disabled"));
+ Assert.Contains(CapturedLogs(logger), e => e.Level == LogLevel.Debug);
+ }
+
+ [Fact]
+ public async Task Send_MissingUrl_WarnsAndSkips()
+ {
+ var (service, handler, logger) = Build(Config(url: ""));
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ Assert.Equal(0, handler.InvocationCount);
+ Assert.Contains(CapturedLogs(logger), e => e.Level == LogLevel.Warning);
+ }
+
+ [Fact]
+ public async Task Send_NonDiscordHost_RejectedWithRedactedLog()
+ {
+ var (service, handler, logger) = Build(Config(url: "https://evil.example/api/webhooks/456/" + SecretToken));
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ Assert.Equal(0, handler.InvocationCount);
+ var logs = CapturedLogs(logger);
+ Assert.Contains(logs, e => e.Level == LogLevel.Warning && e.Message.Contains("https://evil.example"));
+ Assert.DoesNotContain(logs, e => e.Message.Contains(SecretToken));
+ }
+
+ // ---------- retry / failure degradation ----------
+
+ [Fact]
+ public async Task Send_RateLimited_RetriesWithinBudget_ThenSucceeds()
+ {
+ var handler = StubHandler.ReturnsResponses(
+ () =>
+ {
+ var r = new HttpResponseMessage((HttpStatusCode)429);
+ r.Headers.TryAddWithoutValidation("Retry-After", "0");
+ return r;
+ },
+ () => new HttpResponseMessage(HttpStatusCode.NoContent));
+ var (service, _, logger) = Build(handler: handler);
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ Assert.Equal(2, handler.InvocationCount);
+ Assert.Contains(CapturedLogs(logger),
+ e => e.Level == LogLevel.Information && e.Message.Contains("Sent Discord income reinvest report"));
+ }
+
+ [Fact]
+ public async Task Send_TerminalHttpFailure_NoThrow_LogsLoudAlertDropped_WithoutToken()
+ {
+ var handler = StubHandler.ReturnsStatuses(HttpStatusCode.InternalServerError);
+ var (service, _, logger) = Build(handler: handler);
+
+ // Must not throw — observability is never control.
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ Assert.Equal(1, handler.InvocationCount);
+ var logs = CapturedLogs(logger);
+ Assert.Contains(logs, e => e.Level == LogLevel.Error && e.Message.Contains("AlertDropped"));
+ Assert.DoesNotContain(logs, e => e.Message.Contains(SecretToken));
+ }
+
+ [Fact]
+ public async Task Send_TransportFailure_NoThrow_TypeNameOnlyInLogs()
+ {
+ var handler = StubHandler.ReturnsResponses(
+ () => throw new HttpRequestException("connection refused to https://discord.com/api/webhooks/456/" + SecretToken));
+ var (service, _, logger) = Build(handler: handler);
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ var logs = CapturedLogs(logger);
+ Assert.Contains(logs, e => e.Level == LogLevel.Error && e.Message.Contains(nameof(HttpRequestException)));
+ // The exception MESSAGE (which can echo the token-bearing URI) never reaches a log.
+ Assert.DoesNotContain(logs, e => e.Message.Contains(SecretToken));
+ }
+
+ [Fact]
+ public async Task Send_PayloadNeverContainsWebhookToken()
+ {
+ var (service, handler, logger) = Build();
+
+ await service.SendReinvestmentPlanReportAsync(Plan(), State(), ordersPlaced: 0);
+
+ Assert.DoesNotContain(handler.Bodies, b => b.Contains(SecretToken));
+ Assert.DoesNotContain(CapturedLogs(logger), e => e.Message.Contains(SecretToken));
+ }
+}
diff --git a/tests/TradingSystem.Tests/Functions/IncomeReinvestServiceTests.cs b/tests/TradingSystem.Tests/Functions/IncomeReinvestServiceTests.cs
new file mode 100644
index 0000000..7e3736c
--- /dev/null
+++ b/tests/TradingSystem.Tests/Functions/IncomeReinvestServiceTests.cs
@@ -0,0 +1,472 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Moq;
+using TradingSystem.Core.Configuration;
+using TradingSystem.Core.Interfaces;
+using TradingSystem.Core.Models;
+using TradingSystem.Functions;
+using TradingSystem.Strategies.Income;
+using Xunit;
+using MsOptions = Microsoft.Extensions.Options.Options;
+
+namespace TradingSystem.Tests.Functions;
+
+///
+/// S6-001 Rigorous-tier tests for the monthly reinvest pipeline. THE primary sprint
+/// invariant lives here (locked decision 1): with IncomeSleeve:OrderPlacementEnabled at its
+/// default (false), the run computes a ReinvestmentPlan and sends a report but NEVER touches
+/// IExecutionService — zero orders, ever. Per the spec these tests use the REAL
+/// IncomeSleeveManager (caps enforced at plan time — 10% issuer / 40% category, post-buy
+/// semantics) with mocked IBrokerService / IExecutionService and seeded positions, so the
+/// cap and no-order invariants are proven end-to-end through the actual plan generator.
+///
+public class IncomeReinvestServiceTests
+{
+ // 2026-07-01 is a Wednesday — the first trading weekday of July 2026 (the real first
+ // firing this item must protect).
+ private static readonly DateTime FirstWeekdayJul2026 = new(2026, 7, 1, 13, 30, 0, DateTimeKind.Utc);
+
+ // ---------- harness ----------
+
+ private sealed class Fixture
+ {
+ public Mock Broker { get; } = new();
+ public Mock Execution { get; } = new();
+ public Mock Report { get; } = new();
+ public Mock Alerts { get; } = new();
+ public Mock> Logger { get; } = new();
+ public ReinvestmentPlan? CapturedPlan { get; set; }
+ public IncomeSleeveState? CapturedState { get; set; }
+ public int? CapturedOrdersPlaced { get; set; }
+ public IncomeReinvestService Service { get; set; } = null!;
+ }
+
+ private static Fixture Build(
+ bool orderPlacementEnabled = false,
+ List? positions = null,
+ decimal totalCashValue = 100_000m,
+ bool connectSucceeds = true,
+ IncomeConfig? incomeConfig = null,
+ bool includeReportService = true,
+ bool includeAlertService = true,
+ Exception? reportThrows = null,
+ Exception? accountThrows = null)
+ {
+ var fixture = new Fixture();
+
+ fixture.Broker.Setup(b => b.ConnectAsync(It.IsAny()))
+ .ReturnsAsync(connectSucceeds);
+ fixture.Broker.Setup(b => b.DisconnectAsync()).Returns(Task.CompletedTask);
+ fixture.Broker.Setup(b => b.GetPositionsAsync(It.IsAny()))
+ .ReturnsAsync(positions ?? new List());
+
+ var accountSetup = fixture.Broker.Setup(b => b.GetAccountAsync(It.IsAny()));
+ if (accountThrows != null)
+ {
+ accountSetup.ThrowsAsync(accountThrows);
+ }
+ else
+ {
+ accountSetup.ReturnsAsync(new Account
+ {
+ TotalCashValue = totalCashValue,
+ CashBufferValue = 0m
+ });
+ }
+
+ fixture.Broker.Setup(b => b.GetQuotesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((IEnumerable symbols, CancellationToken _) =>
+ symbols.Select(s => new Quote { Symbol = s, Bid = 49.90m, Ask = 50m, Last = 50m }).ToList());
+
+ fixture.Execution.Setup(e => e.ExecuteSignalAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((Signal signal, CancellationToken _) => new ExecutionResult
+ {
+ Success = true,
+ SignalId = signal.Id,
+ Orders = new List { new() { Symbol = signal.Symbol } }
+ });
+
+ var reportSetup = fixture.Report.Setup(r => r.SendReinvestmentPlanReportAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()));
+ if (reportThrows != null)
+ {
+ reportSetup.ThrowsAsync(reportThrows);
+ }
+ else
+ {
+ reportSetup
+ .Callback((plan, state, ordersPlaced, _) =>
+ {
+ fixture.CapturedPlan = plan;
+ fixture.CapturedState = state;
+ fixture.CapturedOrdersPlaced = ordersPlaced;
+ })
+ .Returns(Task.CompletedTask);
+ }
+
+ // REAL plan generator (spec: reuse, never duplicate, the 10%/40% cap logic).
+ var manager = new IncomeSleeveManager(
+ fixture.Broker.Object,
+ fixture.Execution.Object,
+ new IncomeUniverse(),
+ incomeConfig ?? new IncomeConfig(),
+ new ExecutionConfig(),
+ new Mock>().Object);
+
+ fixture.Service = new IncomeReinvestService(
+ fixture.Broker.Object,
+ manager,
+ MsOptions.Create(new TradingSystemConfig()),
+ MsOptions.Create(new IncomeSleeveConfig { OrderPlacementEnabled = orderPlacementEnabled }),
+ fixture.Logger.Object,
+ includeReportService ? fixture.Report.Object : null,
+ includeAlertService ? fixture.Alerts.Object : null);
+
+ return fixture;
+ }
+
+ ///
+ /// Underweight seed: a single 50k DividendGrowthETF position makes every other category
+ /// underweight. With default caps/targets the drift math yields: CoveredCallETF and BDC
+ /// (drift -20% → $10k buys) are SKIPPED by the post-buy issuer cap
+ /// (10k / 60k = 16.7% > 10%), while EquityREIT / MortgageREIT / Preferreds
+ /// (drift -10% → $5k buys, 5k / 55k = 9.09% ≤ 10%) survive → 3 proposed buys.
+ ///
+ private static List UnderweightSleeve() => new()
+ {
+ new Position { Symbol = "VIG", Quantity = 1000m, MarketPrice = 50m, AverageCost = 45m }
+ };
+
+ private static List<(LogLevel Level, string Message)> CapturedLogs(Mock> logger)
+ {
+ var entries = new List<(LogLevel, string)>();
+ foreach (var inv in logger.Invocations)
+ {
+ if (inv.Method.Name != nameof(ILogger.Log))
+ continue;
+ entries.Add(((LogLevel)inv.Arguments[0], inv.Arguments[2]?.ToString() ?? string.Empty));
+ }
+ return entries;
+ }
+
+ // ---------- 1. THE invariant: no orders by default (locked decision 1) ----------
+
+ [Fact]
+ public async Task FlagOff_Default_PlanProposed_ButExecutionServiceNeverCalled()
+ {
+ var fixture = Build(orderPlacementEnabled: false, positions: UnderweightSleeve());
+
+ var result = await fixture.Service.RunAsync("run00001", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.False(result.Skipped);
+ Assert.True(result.BrokerConnected);
+ Assert.True(result.PlanGenerated);
+ Assert.True(result.ProposedBuyCount >= 1);
+
+ // THE sprint invariant: flag off ⇒ IExecutionService is NEVER called.
+ fixture.Execution.Verify(
+ e => e.ExecuteSignalAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ Assert.Equal(0, result.OrdersPlaced);
+
+ // Posture is logged explicitly so the run record states why no orders exist.
+ Assert.Contains(CapturedLogs(fixture.Logger),
+ e => e.Level == LogLevel.Information &&
+ e.Message.Contains("recommendation-only") &&
+ e.Message.Contains("IncomeSleeve:OrderPlacementEnabled=false"));
+ }
+
+ // ---------- 2. Flag on: existing execution path engages ----------
+
+ [Fact]
+ public async Task FlagOn_ExecutesOncePerProposedBuy_AndOrdersPlacedMatches()
+ {
+ var fixture = Build(orderPlacementEnabled: true, positions: UnderweightSleeve());
+
+ var result = await fixture.Service.RunAsync("run00002", FirstWeekdayJul2026, CancellationToken.None);
+
+ // Default seed yields 3 surviving buys (see UnderweightSleeve doc comment).
+ Assert.Equal(3, result.ProposedBuyCount);
+ fixture.Execution.Verify(
+ e => e.ExecuteSignalAsync(It.IsAny(), It.IsAny()),
+ Times.Exactly(3));
+ Assert.Equal(3, result.OrdersPlaced);
+
+ // The report reflects the orders-placed posture.
+ Assert.Equal(3, fixture.CapturedOrdersPlaced);
+ }
+
+ // ---------- 3. Flag default regression tripwire (locked decision 1) ----------
+
+ [Fact]
+ public void IncomeSleeveConfig_CodeDefault_IsOrderPlacementDisabled()
+ {
+ Assert.False(new IncomeSleeveConfig().OrderPlacementEnabled);
+ }
+
+ [Fact]
+ public void IncomeSleeveConfig_UnboundEmptySection_BindsToOrderPlacementDisabled()
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary())
+ .Build();
+
+ var bound = new IncomeSleeveConfig();
+ configuration.GetSection("IncomeSleeve").Bind(bound);
+
+ Assert.False(bound.OrderPlacementEnabled);
+ }
+
+ // ---------- 4. First-trading-weekday gate (Default D3) ----------
+
+ [Fact]
+ public async Task Gate_FirstWeekdayOfMonth_Runs()
+ {
+ var fixture = Build(positions: UnderweightSleeve());
+
+ var result = await fixture.Service.RunAsync("run00003", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.False(result.Skipped);
+ fixture.Broker.Verify(b => b.ConnectAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Gate_SecondWeekdayOfMonth_SkipsWithZeroBrokerCalls()
+ {
+ var fixture = Build(positions: UnderweightSleeve());
+
+ // 2026-07-02 (Thursday) — still inside the cron's day 1-7 window, but not the gate day.
+ var result = await fixture.Service.RunAsync(
+ "run00004", new DateTime(2026, 7, 2, 13, 30, 0, DateTimeKind.Utc), CancellationToken.None);
+
+ Assert.True(result.Skipped);
+ Assert.False(string.IsNullOrWhiteSpace(result.SkipReason));
+ fixture.Broker.Verify(b => b.ConnectAsync(It.IsAny()), Times.Never);
+ fixture.Report.Verify(r => r.SendReinvestmentPlanReportAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+
+ // The skip is an Information log (proof the timer ran), not silence.
+ Assert.Contains(CapturedLogs(fixture.Logger), e => e.Level == LogLevel.Information);
+ }
+
+ [Fact]
+ public async Task Gate_MonthStartingOnSaturday_FirstMondayIsTheGateDay()
+ {
+ // August 2026 starts on a Saturday → the first trading weekday is Monday 2026-08-03.
+ var fixture = Build(positions: UnderweightSleeve());
+
+ var monday = await fixture.Service.RunAsync(
+ "run00005", new DateTime(2026, 8, 3, 13, 30, 0, DateTimeKind.Utc), CancellationToken.None);
+ Assert.False(monday.Skipped);
+
+ var tuesday = await fixture.Service.RunAsync(
+ "run00006", new DateTime(2026, 8, 4, 13, 30, 0, DateTimeKind.Utc), CancellationToken.None);
+ Assert.True(tuesday.Skipped);
+
+ fixture.Broker.Verify(b => b.ConnectAsync(It.IsAny()), Times.Once);
+ }
+
+ // ---------- 5. Broker connect failure (Default D7 — S5-003 reuse) ----------
+
+ [Fact]
+ public async Task ConnectFailure_SendsExactlyOneOperationalAlert_NoPlanNoReportNoThrow()
+ {
+ var fixture = Build(positions: UnderweightSleeve(), connectSucceeds: false);
+
+ var result = await fixture.Service.RunAsync("run00007", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.False(result.BrokerConnected);
+ Assert.False(result.PlanGenerated);
+ Assert.False(result.ReportSent);
+
+ fixture.Alerts.Verify(a => a.SendOperationalAlertAsync(
+ "Broker Connect Failure — Monthly Reinvest",
+ It.Is(d => d.Contains("run00007")),
+ It.IsAny()),
+ Times.Once);
+ fixture.Report.Verify(r => r.SendReinvestmentPlanReportAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ // No successful connect → no disconnect.
+ fixture.Broker.Verify(b => b.DisconnectAsync(), Times.Never);
+ }
+
+ [Fact]
+ public async Task ConnectFailure_WithoutAlertService_StillDegradesQuietly()
+ {
+ var fixture = Build(positions: UnderweightSleeve(), connectSucceeds: false, includeAlertService: false);
+
+ var result = await fixture.Service.RunAsync("run00008", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.False(result.BrokerConnected);
+ }
+
+ [Fact]
+ public async Task ConnectFailure_AlertServiceThrows_NeverFailsTheRun()
+ {
+ var fixture = Build(positions: UnderweightSleeve(), connectSucceeds: false);
+ fixture.Alerts.Setup(a => a.SendOperationalAlertAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("alert transport down"));
+
+ var result = await fixture.Service.RunAsync("run00009", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.False(result.BrokerConnected);
+ }
+
+ // ---------- 6. Caps respected end-to-end through the REAL manager ----------
+
+ [Fact]
+ public async Task IssuerCap_PostBuySemantics_SkipsSymbolsThatWouldExceedTenPercent()
+ {
+ var fixture = Build(positions: UnderweightSleeve());
+
+ await fixture.Service.RunAsync("run00010", FirstWeekdayJul2026, CancellationToken.None);
+
+ var plan = fixture.CapturedPlan;
+ Assert.NotNull(plan);
+
+ // CoveredCallETF and BDC ($10k buys → 10k/60k = 16.7% post-buy) violate the 10%
+ // issuer cap and must be absent; the $5k buys (9.09% post-buy) survive.
+ Assert.DoesNotContain(plan!.ProposedBuys, b => b.Category == IncomeCategory.CoveredCallETF);
+ Assert.DoesNotContain(plan.ProposedBuys, b => b.Category == IncomeCategory.BDC);
+ Assert.Equal(3, plan.ProposedBuys.Count);
+ Assert.All(plan.ProposedBuys, b => Assert.Equal(5_000m, b.Amount));
+ }
+
+ [Fact]
+ public async Task CategoryCap_PostBuySemantics_SkipsCategoryThatWouldExceedFortyPercent()
+ {
+ // Test fixture isolates the CATEGORY cap: issuer cap is opened to 100% so the
+ // category check is the deciding one. (Fixture config only — production caps are
+ // untouched by this item.)
+ var incomeConfig = new IncomeConfig
+ {
+ MaxIssuerPercent = 1.0m,
+ AllocationTargets = new Dictionary
+ {
+ // Drift -75% → $37.5k buy → 37.5k/87.5k = 42.9% post-buy > 40% → skipped.
+ { "CoveredCallETF", 0.75m },
+ // Drift -10% → $5k buy → 5k/55k = 9.09% post-buy ≤ 40% → survives.
+ { "EquityREIT", 0.10m }
+ }
+ };
+ var fixture = Build(positions: UnderweightSleeve(), incomeConfig: incomeConfig);
+
+ await fixture.Service.RunAsync("run00011", FirstWeekdayJul2026, CancellationToken.None);
+
+ var plan = fixture.CapturedPlan;
+ Assert.NotNull(plan);
+ Assert.DoesNotContain(plan!.ProposedBuys, b => b.Category == IncomeCategory.CoveredCallETF);
+ var only = Assert.Single(plan.ProposedBuys);
+ Assert.Equal(IncomeCategory.EquityREIT, only.Category);
+ }
+
+ // ---------- 7. Empty sleeve honesty (Default D8) ----------
+
+ [Fact]
+ public async Task EmptySleeve_YieldsEmptyPlan_AndReportIsStillSent()
+ {
+ var fixture = Build(positions: new List());
+
+ var result = await fixture.Service.RunAsync("run00012", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.True(result.PlanGenerated);
+ Assert.Equal(0, result.ProposedBuyCount);
+ Assert.Equal(0m, result.TotalProposedAmount);
+ Assert.Equal(0, result.OrdersPlaced);
+
+ // Proof the timer ran: the report still goes out saying no buys are proposed.
+ Assert.True(result.ReportSent);
+ Assert.NotNull(fixture.CapturedPlan);
+ Assert.Empty(fixture.CapturedPlan!.ProposedBuys);
+ }
+
+ // ---------- 8. Report failure can never fail the run ----------
+
+ [Fact]
+ public async Task ReportFailure_RunCompletes_WithWarning_AndOrdersOutcomeUnaffected()
+ {
+ var fixture = Build(
+ positions: UnderweightSleeve(),
+ reportThrows: new HttpRequestException("simulated webhook outage"));
+
+ var result = await fixture.Service.RunAsync("run00013", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.True(result.PlanGenerated);
+ Assert.False(result.ReportSent);
+ Assert.NotEmpty(result.Warnings);
+ Assert.Equal(0, result.OrdersPlaced);
+
+ // Warning content carries the exception TYPE NAME only — never the message.
+ Assert.Contains(result.Warnings, w => w.Contains(nameof(HttpRequestException)));
+ Assert.DoesNotContain(result.Warnings, w => w.Contains("simulated webhook outage"));
+ }
+
+ [Fact]
+ public async Task NoReportServiceRegistered_RunCompletes_ReportSentFalse()
+ {
+ var fixture = Build(positions: UnderweightSleeve(), includeReportService: false);
+
+ var result = await fixture.Service.RunAsync("run00014", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.True(result.PlanGenerated);
+ Assert.False(result.ReportSent);
+ }
+
+ // ---------- 9. D4: recommendation-cash input ----------
+
+ [Fact]
+ public async Task AvailableCash_IsTotalCashValueTimesIncomeTargetPercent_AndDividendFieldsStayZero()
+ {
+ var fixture = Build(positions: UnderweightSleeve(), totalCashValue: 100_000m);
+
+ await fixture.Service.RunAsync("run00015", FirstWeekdayJul2026, CancellationToken.None);
+
+ Assert.NotNull(fixture.CapturedPlan);
+ // 100,000 × 0.70 (default IncomeTargetPercent) = 70,000 — recorded in the plan so the
+ // owner sees the input (Default D4).
+ Assert.Equal(70_000m, fixture.CapturedPlan!.AvailableCash);
+ // No dividend-activity data source yet — fields stay default-valued, never guessed.
+ Assert.Equal(0m, fixture.CapturedPlan.DividendsReceived);
+ Assert.Equal(0m, fixture.CapturedPlan.InterestReceived);
+ }
+
+ // ---------- 10. Disconnect finally-semantics ----------
+
+ [Fact]
+ public async Task Disconnect_CalledExactlyOnce_AfterSuccessfulRun()
+ {
+ var fixture = Build(positions: UnderweightSleeve());
+
+ await fixture.Service.RunAsync("run00016", FirstWeekdayJul2026, CancellationToken.None);
+
+ fixture.Broker.Verify(b => b.DisconnectAsync(), Times.Once);
+ }
+
+ [Fact]
+ public async Task Disconnect_CalledExactlyOnce_EvenWhenPlanGenerationThrows()
+ {
+ var fixture = Build(
+ positions: UnderweightSleeve(),
+ accountThrows: new InvalidOperationException("account fetch failed"));
+
+ await Assert.ThrowsAsync(() =>
+ fixture.Service.RunAsync("run00017", FirstWeekdayJul2026, CancellationToken.None));
+
+ fixture.Broker.Verify(b => b.DisconnectAsync(), Times.Once);
+ }
+
+ [Fact]
+ public async Task Disconnect_CalledExactlyOnce_WhenReportThrows()
+ {
+ var fixture = Build(
+ positions: UnderweightSleeve(),
+ reportThrows: new InvalidOperationException("report renderer bug"));
+
+ await fixture.Service.RunAsync("run00018", FirstWeekdayJul2026, CancellationToken.None);
+
+ fixture.Broker.Verify(b => b.DisconnectAsync(), Times.Once);
+ }
+}
diff --git a/tests/TradingSystem.Tests/Functions/IncomeSleeveFunctionTests.cs b/tests/TradingSystem.Tests/Functions/IncomeSleeveFunctionTests.cs
index 48990f9..7d10014 100644
--- a/tests/TradingSystem.Tests/Functions/IncomeSleeveFunctionTests.cs
+++ b/tests/TradingSystem.Tests/Functions/IncomeSleeveFunctionTests.cs
@@ -1,9 +1,11 @@
using System.Text.RegularExpressions;
using Microsoft.Azure.Functions.Worker;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using TradingSystem.Core.Configuration;
+using TradingSystem.Core.Interfaces;
using TradingSystem.Functions;
using Xunit;
using MsOptions = Microsoft.Extensions.Options.Options;
@@ -15,19 +17,29 @@ namespace TradingSystem.Tests.Functions;
/// evidence — a TODO stub that logs "Quarterly quality audit complete" while doing nothing
/// would falsify the run record in the Jul 1–7 audit window. The stub must instead log an
/// explicit Warning that it is not implemented and was skipped. Also covers the internal
-/// Func<DateTime> clock seam (Default D1) that S6-001 rebases on: both timer methods
-/// read the injected clock instead of DateTime.UtcNow, so the day-of-month guard is testable.
-/// No Discord send, no cron change, no behavior added.
+/// Func<DateTime> clock seam (Default D1) that S6-001 builds on.
+///
+/// S6-001: RunMonthlyReinvest is now a THIN timer wrapper (S5-001 / DailyOrchestrator EOD
+/// pattern): cheap Day>7 pre-filter, null-tolerant IIncomeReinvestService resolve
+/// (ADR-024 — warn + return when unregistered), delegate with (runId, utcNow, ct) via the
+/// clock seam, log the structured result, and on an unhandled exception send the
+/// "Orchestration Run Failure — Monthly Reinvest" operational alert (exception TYPE NAME
+/// only) before rethrowing (Default D7).
///
public class IncomeSleeveFunctionTests
{
// ---------- harness ----------
- private static (IncomeSleeveFunction Function, Mock> Logger) Build(DateTime pinnedUtcNow)
+ private static (IncomeSleeveFunction Function, Mock> Logger) Build(
+ DateTime pinnedUtcNow,
+ Action? configureServices = null)
{
var logger = new Mock>();
var config = MsOptions.Create(new TradingSystemConfig());
- var function = new IncomeSleeveFunction(logger.Object, config, () => pinnedUtcNow);
+ var services = new ServiceCollection();
+ configureServices?.Invoke(services);
+ var function = new IncomeSleeveFunction(
+ logger.Object, config, services.BuildServiceProvider(), () => pinnedUtcNow);
return (function, logger);
}
@@ -45,6 +57,9 @@ private static (IncomeSleeveFunction Function, Mock e.Level == LogLevel.Information)
- .Select(e => e.Message)
- .ToList();
- Assert.Contains(informationMessages, m => m.Contains("Monthly income reinvest complete"));
+ Assert.Contains(CapturedLogs(logger),
+ e => e.Level == LogLevel.Warning && e.Message.Contains("IIncomeReinvestService not registered"));
}
[Fact]
- public async Task RunMonthlyReinvest_AfterFirstWeek_GuardUsesInjectedClock()
+ public async Task RunMonthlyReinvest_DelegatesWithRunIdAndPinnedClock_AndLogsResult()
{
- // Proves the monthly method reads the seam (S6-001 rebases on this), guard intact.
- var (function, logger) = Build(new DateTime(2026, 7, 8, 13, 30, 0, DateTimeKind.Utc));
+ var reinvest = new Mock();
+ reinvest.Setup(s => s.RunAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new IncomeReinvestResult
+ {
+ BrokerConnected = true,
+ PlanGenerated = true,
+ ProposedBuyCount = 3,
+ TotalProposedAmount = 15_000m,
+ OrdersPlaced = 0,
+ ReportSent = true
+ });
+
+ var (function, logger) = Build(FirstWeekdayJul2026,
+ s => s.AddSingleton(reinvest.Object));
+
+ await function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None);
+
+ // Delegated exactly once, with the pinned clock value and an 8-char runId.
+ reinvest.Verify(s => s.RunAsync(
+ It.Is(id => id.Length == 8),
+ FirstWeekdayJul2026,
+ It.IsAny()),
+ Times.Once);
+
+ // The structured result is logged (run-record evidence). SkipReason is additive
+ // (S6-001 review fix 4): present on every finish line, empty when not skipped, so a
+ // gate-skip day is distinguishable from a broker-failure day in logs.
+ Assert.Contains(CapturedLogs(logger),
+ e => e.Level == LogLevel.Information &&
+ e.Message.Contains("OrdersPlaced: 0") &&
+ e.Message.Contains("ProposedBuyCount: 3") &&
+ e.Message.Contains("SkipReason:"));
+ }
+
+ [Fact]
+ public async Task RunMonthlyReinvest_ResultWarnings_AreSurfacedAsWarningLog()
+ {
+ var reinvest = new Mock();
+ reinvest.Setup(s => s.RunAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new IncomeReinvestResult
+ {
+ BrokerConnected = true,
+ PlanGenerated = true,
+ Warnings = { "Income reinvest report failed (HttpRequestException)." }
+ });
+
+ var (function, logger) = Build(FirstWeekdayJul2026, s => s.AddSingleton(reinvest.Object));
+
+ await function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None);
+
+ Assert.Contains(CapturedLogs(logger),
+ e => e.Level == LogLevel.Warning && e.Message.Contains("Income reinvest report failed"));
+ }
+
+ [Fact]
+ public async Task RunMonthlyReinvest_AfterFirstWeek_GuardSkipsBeforeResolvingService()
+ {
+ // Day 8 (> 7): the cheap pre-filter returns before any resolve/log — the
+ // authoritative first-weekday gate lives in IncomeReinvestService (Default D3).
+ var reinvest = new Mock(MockBehavior.Strict);
+ var (function, logger) = Build(
+ new DateTime(2026, 7, 8, 13, 30, 0, DateTimeKind.Utc),
+ s => s.AddSingleton(reinvest.Object));
await function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None);
Assert.Empty(CapturedLogs(logger));
+ reinvest.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task RunMonthlyReinvest_ServiceThrows_SendsOrchestrationFailureAlert_ThenRethrows()
+ {
+ var reinvest = new Mock();
+ reinvest.Setup(s => s.RunAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("secret-bearing detail that must not leak"));
+
+ var alerts = new Mock();
+
+ var (function, _) = Build(FirstWeekdayJul2026, s =>
+ {
+ s.AddSingleton(reinvest.Object);
+ s.AddSingleton(alerts.Object);
+ });
+
+ await Assert.ThrowsAsync(() =>
+ function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None));
+
+ // Exactly one operational alert, exception TYPE NAME only — never the message.
+ alerts.Verify(a => a.SendOperationalAlertAsync(
+ "Orchestration Run Failure — Monthly Reinvest",
+ It.Is(d => d.Contains(nameof(InvalidOperationException)) &&
+ !d.Contains("secret-bearing")),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task RunMonthlyReinvest_ServiceThrows_NoAlertServiceRegistered_StillRethrows()
+ {
+ var reinvest = new Mock();
+ reinvest.Setup(s => s.RunAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("boom"));
+
+ var (function, _) = Build(FirstWeekdayJul2026, s => s.AddSingleton(reinvest.Object));
+
+ await Assert.ThrowsAsync(() =>
+ function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RunMonthlyReinvest_AlertSendFailure_DoesNotMaskTheOriginalException()
+ {
+ var reinvest = new Mock();
+ reinvest.Setup(s => s.RunAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("original failure"));
+
+ var alerts = new Mock();
+ alerts.Setup(a => a.SendOperationalAlertAsync(
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new HttpRequestException("alert transport down"));
+
+ var (function, _) = Build(FirstWeekdayJul2026, s =>
+ {
+ s.AddSingleton(reinvest.Object);
+ s.AddSingleton(alerts.Object);
+ });
+
+ // The ORIGINAL exception type propagates — the alert failure is swallowed.
+ await Assert.ThrowsAsync(() =>
+ function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None));
}
}