An allocation process that can be replayed, argued with, and shown to be wrong
MacroSense: a regime and sentiment allocator that tests whether one person can build and run the repeatable allocation process an investment committee is supposed to run, on data anybody can look up. I built it alone. Each trading day it sorts the market into one of four regimes (market environments such as calm growth or rate stress) and sets weights across fifteen funds.
In progress MacroSense runs end to end. It has not been validated. The backtest (replaying the strategy over past data as if it were live) is built, but the history it can run over is fifty-six trading days. That cannot support a return, a ratio or a probability of beating a benchmark, so no performance figure appears on this page. Extending the history is the current work.
What this Engagement produced
-
13 modules
Each separable and runnable alone; the seven carrying the loop are named below with their inputs and outputs. -
4 macro regimes
Calm Growth, Turbulent Growth, Rate Stress and Risk Off, each with its own allocation constraints. -
56 trading days of history
All the history the backtest can run over so far.
The Situation
Allocation decisions made by hand are usually made once, defended in a meeting, and never recorded in a form anybody can re-run. Six months later there is no process to question, only a sequence of opinions.
A system that runs on a schedule from stated inputs, and writes down what it did and why, can be checked later. So I set out to build the whole loop alone, with no step that works only because somebody is watching.
MacroSense has two layers. The macro layer reads market data, classifies the regime, and sets weights across fifteen sector and credit exchange-traded funds. The company layer scores what companies say in their filings and earnings calls, and passes a sector score back to the macro layer.
One constraint was fixed at the start and never relaxed: every input is free and public. Treasury yields and credit spreads come from the Federal Reserve’s published series. Volatility and fund prices come from public market data. Company commentary comes from 10-K and 10-Q filings and earnings-call transcripts.
Why every input is free and public
Cost is part of it, but not the main part. A system built on a subscription cannot be reproduced by a reader or checked by somebody who doubts it. A process whose whole claim is that it repeats should not have a paywall in the middle of it. The price is that free sources go missing: identifiers get retired, endpoints change shape, requests come back empty. So each input has a list of backup sources, tried in order.
What I Did
I built every part myself. MacroSense has thirteen modules, each runnable on its own, and the seven below carry the daily loop. Each takes a stated input and returns a stated output.
Exhibit 1 The seven components that carry the loop
Select a component to see what goes in and what comes out.
View the data
| Component | In | Out | Hands its output to |
|---|---|---|---|
| Data fetcher | Public macro series and public market prices — Treasury yields at two and ten years and the spread between them, high-yield and investment-grade credit spreads (the extra yield corporate bonds pay over Treasuries), the volatility index and its term structure (how expected volatility differs between the next month and the next three), and one- and three-month momentum (recent price return) for each of fifteen sector and credit exchange-traded funds. | One dated row, fifty-eight columns wide. | Regime classifier, Portfolio optimizer |
| Regime classifier | A rolling window of at least twenty of those rows, plus sentiment signals for the energy and healthcare sectors. | One of four regimes — Calm Growth, Turbulent Growth, Rate Stress, Risk Off — with a probability for each. | Portfolio optimizer, Briefing, Dashboard |
| Portfolio optimizer | The regime and the current signal row. | Weights across the same fifteen funds, summing to one. | Briefing, Dashboard |
| Sentiment scorer | Earnings-call transcripts and 10-K and 10-Q filings from the SEC’s public archive, collected by a companion updater that waits between requests to stay under the SEC’s rate limit. | A score for each company on four dimensions — revenue tone, cost and margin direction, confident versus hedged language, newly disclosed risk — a weighted composite of the four, and a sector roll-up that goes back to the classifier as one of its features (the inputs a model learns from). | Regime classifier, Briefing, Dashboard |
| Scheduler | The clock and the exchange calendar. | A completed cycle, logged. | No other component |
| Briefing | The regime name, the probability vector, the previous weights, the new weights, the sentiment signals. | A few sentences of plain English saying what changed and why. | Dashboard |
| Dashboard | The regime and its four probabilities, the weights, the sentiment scores, and every past rebalance, which is saved to a local database. | One page showing where the market stands, what the portfolio holds and why, and how both have changed over time. | No other component |
The data fetcher. In: public macro series and public market prices — Treasury yields at two and ten years and the spread between them, high-yield and investment-grade credit spreads (the extra yield corporate bonds pay over Treasuries), the volatility index and its term structure (how expected volatility differs between the next month and the next three), and one- and three-month momentum (recent price return) for each of fifteen sector and credit exchange-traded funds. Out: one dated row, fifty-eight columns wide.
The regime classifier. In: a rolling window of at least twenty of those rows, plus sentiment signals for the energy and healthcare sectors. Out: one of four regimes — Calm Growth, Turbulent Growth, Rate Stress, Risk Off — with a probability for each. The model is a four-state Gaussian hidden Markov model. It treats the market’s regime as something that can’t be seen directly, and works out which of the four states most likely produced the data. The probabilities matter more than the label: 0.41 against a runner-up at 0.39 says something different from 0.90. So the briefing gets all four probabilities, not just the top one.
The portfolio optimizer. In: the regime and the current signal row. Out: weights across the same fifteen funds, summing to one. Each regime changes the constraints (rules the weights must obey), not the model. In the two growth regimes the portfolio is long only, with a cap per position. In the two stress regimes short positions are allowed within tighter bounds, defensive holdings are favored, and rate-sensitive ones are penalized. A convex solver (a method that finds the single best set of weights under those rules) does the work. If the solver fails, the optimizer falls back to equal weights and logs the reason as an error.
Exhibit 2 Why the briefing gets all four probabilities, not just the top one
Pick the leading regime, then drag from a close call to a decisive one.
View the data
| Regime | Constraint set | Close call | Decisive |
|---|---|---|---|
| Calm Growth | Long only, with a cap per position | 0.41 | 0.90 |
| Turbulent Growth | Long only, with a cap per position | 0.39 | 0.06 |
| Rate Stress | Short positions allowed within tighter bounds; defensive holdings favored, rate-sensitive ones penalized | 0.12 | 0.02 |
| Risk Off | Short positions allowed within tighter bounds; defensive holdings favored, rate-sensitive ones penalized | 0.08 | 0.02 |
The sentiment scorer. In: earnings-call transcripts and 10-K and 10-Q filings from the SEC’s public archive, collected by a companion updater that waits between requests to stay under the SEC’s rate limit. Out: a score for each company on four dimensions — revenue tone, cost and margin direction, confident versus hedged language, newly disclosed risk — a weighted composite of the four, and a sector roll-up that goes back to the classifier as one of its features (the inputs a model learns from). This is the company layer.
The scheduler. In: the clock and the exchange calendar. Out: a completed cycle, logged. Three jobs — a rebalance after each trading day’s close, a model retrain on the first of the month, a filings pull on Sunday. Each step can fail without stopping the rest, because the scheduler has to keep running for months with nobody watching it.
Exhibit 3 The scheduler’s week: three jobs, each on its own calendar
Pick the day the first of the month falls on, and watch the retrain move while the other jobs stay put.
View the data
| Job | Runs |
|---|---|
| Rebalance | After each trading day’s close |
| Model retrain | On the first of the month |
| Filings pull | On Sunday |
The briefing. In: the regime name, the probability vector, the previous weights, the new weights, the sentiment signals. Out: a few sentences of plain English saying what changed and why. A language model writes them. With no API key available, it falls back to a fixed template, so the record has no gaps on days an outside service was unreachable.
The dashboard. In: the regime and its four probabilities, the weights, the sentiment scores, and every past rebalance, which is saved to a local database. Out: one page showing where the market stands, what the portfolio holds and why, and how both have changed over time.
The rule that kept the components separable
I wrote down a short list of interfaces (what each module takes in and hands back) and treated it as fixed. It covered what the classifier is called with and what it returns, that the optimizer returns weights keyed by ticker summing to one, and the five arguments the briefing takes. Everything behind those interfaces was rewritten repeatedly, the fetcher most of all, and none of those rewrites reached another module. Without fixed interfaces, every change is a whole-system change. Then you stop making improvements, because you can’t tell what each one will break.
What Changed
The system works end to end. On schedule it fetches, classifies, allocates, explains itself and logs the result. It has kept doing that, not just in a one-time demo. That is what I set out to build, and it is done.
Running it also turned up two defects. The optimizer’s equal-weight fallback is meant for a solver that fails, and it logs that as an error. But when the optimizer’s import failed, it took the same fallback and kept producing weights that looked normal. Separately, an alert channel was built but never configured, so every alert returned quietly and reached nobody.
The backtest is built too. It reuses the live classifier and the live optimizer unchanged, because a backtest that rewrites the strategy ends up testing the rewrite. It works in three steps. First, it walks the history forward one rebalance at a time, classifying on data up to that date and no further. Second, it applies the real daily fund returns that followed and charges a cost for turnover (how much of the portfolio is traded). Third, it compares the result with buying and holding the broad market. It computes what a reader would ask for: total return, a compound growth rate, a Sharpe ratio (return per unit of risk), volatility, maximum drawdown (the largest fall from a peak), results by regime, and a distribution of outcomes.
Pending Result Whether MacroSense allocates well is not answered here. The continuous history it has accumulated is fifty-six trading days, from 7 January to 27 March 2026. Replayed at the backtest’s default weekly rebalance that is roughly eleven decisions; monthly, two. Every ratio above would be annualized (scaled up to a yearly rate) from a single market environment three months long. A figure produced that way describes the window, not the system. The current work is backfilling the snapshot series so the model can run across several years and several regimes. The numbers go on this page when the window can carry them.
Exhibit 4 The whole window holds a handful of decisions
Pick a rebalance frequency to count the decisions the window holds.
View the data
| Rebalance | Decisions in the window |
|---|---|
| Every trading day | 56 |
| Weekly, the backtest’s default | roughly 11 |
| Monthly | 2 |
The backtest also has no sentiment data. Those columns are missing from the history, so the classifier fills them with zeros. Only the macro layer has been tested; the company layer has not.
What I Learned
Fifty-six trading days is one market environment, not a sample. The first time the backtest drew an equity curve (the portfolio’s value over time) next to its benchmark, the shape felt like a result. It was not one. The ratios a backtest prints are annualized, which makes three months look like a year, and nothing in the output says so. Sample size decides whether a number means anything at all.
The harder lesson is that a system which runs is very satisfying, and the satisfaction is misleading. Every day the scheduler fires, a cycle completes, a briefing is written and a row lands in the database. None of that is evidence about whether the allocations are any good.
What it would take for a number to go on this page
A history long enough to contain all four regimes, with enough time in each to test that regime’s constraints against real returns. Scored sentiment across that history instead of zeros. And the result reported with its window, its costs, and the periods where it went the other way.
Where Else This Applies
Ask what would make the output wrong. Any automated process handed to a client, such as a nightly reporting refresh, a scoring model or a self-updating forecast, offers a clean run as its best evidence. A clean run says nothing about whether the output is right. The useful question is what would have to be true for the output to be wrong, and whether anybody has checked.
State the window in the same breath as the figure. Think of a six-week pilot, a two-week test, or a model checked against one quarter. Each gives an estimate uncertain enough to contain the opposite conclusion, and each is often reported as a finding. Report the window with the number, and never annualize a window too short to support it.
Make the degraded path announce itself. Both defects in What Changed kept producing output after a component had stopped working. That is worse than stopping, because nothing downstream can tell. Any monitoring or integration work meets this in some form, and a fallback that stays silent costs the most.