Portfolio intelligence, ten agents deep
This is the math and the wiring behind IMP Portfolio Manager. The Idiya Market Phasor pipeline gives us a complex number per stock per day. This system extrapolates where each stock is going, trades on the projected conviction — not today's — and uses incoming bars to confirm or exit. Ten decisions, five to enter, five to exit, all recorded with full phasor state.
1 · The one idea
Buying and selling a stock is not one decision. It's five. What to buy, when to buy, how much to buy, at what price, and — underneath all of it — should you be buying at all right now. Selling is the same five in reverse. Ten questions in total, and they are not independent.
But there's a deeper shift. We don't trade on today's state. We trade on where the stock is going. The phasor gives us θ(t) — where the stock is in the Wyckoff cycle right now. From angular velocity and acceleration, we extrapolate θ̂(t+k) — where it will be at the holding horizon. The signal agent filters by projected conviction, not current conviction. Incoming bars after entry either confirm or disprove the trajectory. That's the observe loop: stay or leave.
2 · Ten questions, two chains
The system runs two parallel five-question chains every cycle:
|
Buy chain
Market management
Q1 environment → Q2 signal → Q3 timing → Q4 sizing → Q5 price
|
Sell chain
Portfolio management
Q1 portfolio env → Q2 signal → Q3 timing → Q4 sizing → Q5 price
|
Each question is answered by one agent. Each agent has a deterministic pre-filter (math) followed by a curated narrative (Claude). The math decides what's eligible. Claude decides what's worth attention. Order matters: environment gates everything downstream.
3 · The dependency graph
The ten agents are a directed graph, not a flat list. Upstream agents constrain the search space of downstream agents. An agent never runs until its parents have produced output.
Concretely: if the environment agent returns overall_action = neutral, the buy chain halts. If it returns risk_budget < 0.20, the sizing agent is capped at 2% per position regardless of what Kelly suggests. If the signal agent produces an empty list, nothing reaches timing. The graph is enforced in [agents/agent_runner.js](phasor_pm/agents/agent_runner.js), not in the UI, because a UI-only gate is a gate that can be bypassed by calling the API directly.
4 · The four convictions
Every ticker in the universe is tagged every night with one of four convictions, derived from its phasor state. These come directly from the Market Phasor framework: the visible price move (Re(z)) and the hidden flow pressure (Im(z)).
|
hl · High-conviction long
Buyable
Visible up, hidden up. Both axes agree. The signal agent's primary input.
|
dw · Divergence warning
Trim / exit
Visible up, hidden down. Distribution. Never bought. Always reviewed if held.
|
|
pr · Pre-reversal
Watchlist
Visible down, hidden up. Bottom forming. Surfaces, never auto-buys.
|
he · High-conviction exit
Force sell
Visible down, hidden down. No floor. Any open position must be reviewed this cycle.
|
Conviction is stored with every position entry and every exit. The delta (entry conviction → exit conviction) is the post-hoc explanation for why the trade worked or didn't — recorded automatically, not reconstructed.
5 · Environment gating
Before any individual stock is considered, the environment agent computes a single market-wide number: universe coherence. This is the fraction of tickers whose phasors are rotating in the same direction as their sector median. It answers one question: is the market moving together, or is it fragmented?
env = Rotation if 0.35 ≤ coherence < 0.60
env = Chaotic if coherence < 0.35
- Trend — buy leaders in leading sectors. Risk budget 100%.
- Rotation — buy only relative leaders. Risk budget 60%. Kelly cap halved.
- Chaotic — no new entries. Only
heexits execute. Risk budget 0%.
The sell chain's environment agent runs a different computation: portfolio-weighted coherence of currently open positions. If that number drops faster than the universe's, the portfolio is decoupling from the market — which usually means the portfolio is wrong, not the market.
6 · Sizing: coherence-damped Kelly
The sizing agent is the one place where math fully overrides Claude. Given an edge estimate p (probability the trade wins) and a payoff ratio b (win:loss), the classic Kelly fraction is:
The framework modifies this in two ways. First, Kelly is clipped to a ceiling (default 0.25 — quarter Kelly) to survive parameter noise in p. Second, it's multiplied by the universe coherence at the moment of the entry, which rises and falls with the environment:
The coherence multiplier is the key. In a Trend regime it barely matters (coherence ≈ 0.7). In a Rotation regime it shrinks positions to ~half their Kelly size. In Chaotic, it goes to zero and no new entry is possible. The same formula that opens positions aggressively in calm markets refuses to open any in loud ones — with no human intervention and no separate "risk-off" switch.
Smart money management — cash-aware sequential sizing
The Kelly section above tells you how much to size a single position. This section is about everything else: where the cash actually comes from, what stops a strategy from over-drawing, and why the backtest engine refuses to "deploy capital at will."
The problem with naive backtests
A typical phasor-driven backtest sees five new entries in a single day, sizes each one independently against the day's portfolio equity, and "executes" all five — even if their combined notional is twice the available cash. The backtest looks great because every entry was timed. The result is a number that no live system could reproduce, because no live broker would let you buy without the cash to pay for it.
We hit this exact problem early. The Conservative strategy was returning −17% in backtest while the live ledger insisted on −7.8%. The gap was not strategy quality. It was that the backtest had silently financed itself with cash it didn't have.
The reconcile-cash-and-value loop
The fix is to reconcile portfolio cash and mark-to-market value between every trade. The function reconcileCashAndValue(), ported from the live engine into the backtest ledger, walks the open positions and updates:
available_cash = cash − Σ(pending_entry_notional)
It runs at three points: before the day's signal pass, before every individual entry, and again after every exit. The sizing agent reads available_cash, not yesterday's snapshot of portfolio_value. A position that would have been allowed under naive sizing is rejected if the cash isn't there.
Sequential cash-aware sizing
When the buy chain produces five candidates in one day, they are sized one at a time, not in parallel. The first candidate sizes against the full available cash. The second sizes against cash after committing to candidate one. The fifth — if it survives — sizes against whatever is left.
This sequential pass has two consequences:
- Order matters. The signal agent ranks candidates by score before the sizing pass, so the highest-conviction names get the cash first.
- Late candidates are often dropped entirely (size = 0). This is the correct behaviour: you cannot buy what you cannot afford. The ledger records the rejection with a reason (
insufficient_cash), so the backtest log explains its own discipline.
The daily deployment cap
Even with cash discipline, a strategy can over-trade. A bull-market day might surface twenty fresh markup candidates with high projected conviction; sequential sizing would happily commit 80% of NAV in one session. We add a daily deployment cap — a hard ceiling on the fraction of NAV that may be moved into new positions on a single bar.
| Profile | Daily deployment cap | Reason |
|---|---|---|
| Conservative | 10% of NAV | Forces incremental entry. Rebuilds the basket over a week, not a day. |
| Balanced | 20% of NAV | Default — captures momentum without aggressive concentration. |
| Aggressive | 40% of NAV | Allows the strategy to act on a clear environment without artificial slow-walking. |
NAV-relative drawdown ceiling
Sizing tells you how much to enter. The drawdown ceiling tells you when to stop entering at all. For each strategy we track:
If drawdown exceeds the per-profile ceiling (10% / 15% / 20%), the strategy stops opening any new positions until NAV recovers above the threshold. Existing positions are still managed by the sell chain — exits fire as normal — but the buy chain is silenced. This isn't a stop-loss on individual trades; it's a circuit-breaker on the strategy itself.
Three risk-tier strategies — same engine, three personalities
One signal pipeline drives three different strategies — Conservative, Balanced, Aggressive — that share the math but differ in the regimes they will act on, how much they size, and how aggressively they exit. The engine is identical; the personality is configuration.
The shared core
Every profile uses the same:
- Phasor pipeline (causal columns, regime labels, conviction tags)
- Environment classifier (TREND / ROTATION / CHAOTIC from coherence decomposition)
- Position Intelligence per-bar tags (early / mid / late / exhausted)
- Reconcile loop and sequential sizing engine
- Live ↔ backtest parity guarantee
What changes between profiles is a single configuration block: which regimes to enter on, what to require for confirmation, how aggressively to size, and when to fold.
Conservative
| Knob | Conservative |
|---|---|
| Entry regimes | accumulation, re_accumulation only |
| Environment gate | TREND only — no entries during ROTATION or CHAOTIC |
| TREND-direction filter | Required: pct_bullish > 0.50 |
| PI requirement | pi_position_in_zone ∈ {early, mid} |
| Coherence floor | 0.65 |
| Kelly cap | 0.10 per position |
| Daily deployment cap | 10% of NAV |
| Drawdown ceiling | 10% |
| Late-markup θ exit | 110° |
The conservative profile is "buy the bottom of the cycle, sell halfway up the markup." It refuses ROTATION environments, which historically saw most of its losers, and it refuses to add to a position that's already late in its zone. Holding period is measured in weeks. Win rate is high; per-trade payoff is modest.
Balanced
| Knob | Balanced |
|---|---|
| Entry regimes | accumulation, re_accumulation, markup |
| Environment gate | TREND or ROTATION (both require direction confirmation) |
| TREND-direction filter | Required in TREND and ROTATION: pct_bullish > 0.50 |
| PI requirement | pi_position_in_zone ∈ {early, mid} |
| Coherence floor | 0.50 |
| Kelly cap | 0.20 per position |
| Daily deployment cap | 20% of NAV |
| Drawdown ceiling | 15% |
| Late-markup θ exit | 120° |
The default profile. Will participate in markup if the environment confirms the direction, but still refuses bare distribution / markdown setups. The TREND-direction filter is the single most important post-launch fix — without it, the strategy was getting chopped on bullish-looking ROTATION environments where the bullish stocks were a minority.
Aggressive
| Knob | Aggressive |
|---|---|
| Entry regimes | accumulation, re_accumulation, markup |
| Environment gate | TREND or ROTATION |
| TREND-direction filter | Required only in ROTATION |
| PI requirement | pi_position_in_zone ∈ {early, mid, late} |
| Coherence floor | 0.40 |
| Kelly cap | 0.40 per position |
| Daily deployment cap | 40% of NAV |
| Drawdown ceiling | 20% |
| Late-markup θ exit | 130° |
The aggressive profile will chase late markup, accept lower-clarity environments, and size bigger. Higher win-rate volatility and bigger drawdowns are the price. The drawdown ceiling at 20% is non-negotiable — even the most aggressive profile has an automatic timeout.
The TREND-direction filter — the +2.6% fix
An early version of Balanced silently accepted any TREND environment as bullish-permissive. In practice, TREND just means "stocks are moving together" — they could be moving together down. We added a one-line filter requiring pct_bullish > 0.50 (the fraction of the active universe currently in markup or accumulation must exceed half) before a TREND environment counts as buyable.
Result, on the same backtest window:
| Profile | Before filter | After filter | Δ |
|---|---|---|---|
| Conservative | −17.0% | −15.3% | +1.7% |
| Balanced | −6.5% | −3.9% | +2.6% |
| Aggressive | +4.1% | +6.4% | +2.3% |
One line of YAML, three strategies improved. The biggest single gain came from the smallest piece of code — a reminder that the strategy lives in the configuration, not in the engine.
7 · Q4 alerts and exits
Q4 is shorthand for the fourth quadrant of the phasor plane — visible up, hidden down — the distribution warning. When a stock that the portfolio holds crosses into Q4, the sell chain fires a Q4 alert. The alert has two effects:
- The position is immediately queued for review in the sell chain's signal agent, regardless of P&L.
- The ledger records
imag_sign_changed = 1, flagging the crossing for later attribution analysis.
The alert is a review trigger, not an auto-sell. The timing agent still has to confirm that now is the right moment to exit. But once a stock is in Q4, the system will keep asking "sell?" every cycle until it's either out of the quadrant or out of the portfolio.
Wyckoff timing knobs — extended exit logic
The Q4 alert above is one trigger. The full sell chain has three more, each tied to a Wyckoff lifecycle observation, each individually tunable per strategy. Together they let a profile decide how aggressively it folds a position before the price actually breaks.
entry_regimes_allowed
Before any entry, the timing agent checks the candidate's current regime against the strategy's whitelist. Conservative whitelists only {accumulation, re_accumulation}. Aggressive adds markup. None of the profiles whitelist distribution, markdown, or capitulation for entry — those are exit-only states.
A candidate's regime is computed from its phasor on every bar; the gate runs every bar. A markup-only profile that has been holding through a rollover into distribution will not be re-allowed to enter on a subsequent bar at the same price — the gate stays closed until the regime cycles back into a whitelisted state.
late_markup_exit_theta
Markup is buyable. Late markup is not — by the time θ has rotated past 120° or so, the move's energy is bleeding into distribution. We define a per-profile threshold: if any open position's θ exceeds late_markup_exit_theta, the position is queued for exit at the next bar.
| Profile | Threshold | Behaviour |
|---|---|---|
| Conservative | 110° | Sells halfway through markup. Leaves money on the table; rarely sells into distribution. |
| Balanced | 120° | Sells near the top of markup. Catches more of the move; occasionally sells the early distribution bar. |
| Aggressive | 130° | Sells deep into markup. Sometimes captures the parabolic blow-off; pays for it with worse exits. |
The threshold is a θ value, not a price. A stock with θ=120° at 1,000 IDR and a stock with θ=120° at 10,000 IDR exit at the same point in their cycle, regardless of currency, sector, or absolute price level.
exit_on_projected_distribution
Phase extrapolation projects θ at horizons H1–H10. If the projection at H5 has θ̂ already in distribution territory (> 135°), the position is flagged for exit now, even if today's θ is still in markup. This is the "trade the trajectory" exit — fold while the price is still healthy because the trajectory says it won't be in five bars.
Anticipatory exit — pi_anticipatory_exit
Position Intelligence (described later in this document) computes a per-bar anticipatory-exit flag: the bar is in late-zone of its current regime AND the projected next regime is bearish. When this flag fires, the sell chain treats it as a hard exit, on par with a Q4 alert. This catches the majority of "regime drift" losers that the regime label alone misses, because the label only changes after the rollover, while PI sees the rollover coming.
The combined effect
Layering all four exit triggers — Q4 alert, entry-regime gate, late-markup θ, projected-distribution, anticipatory-exit — produces a sell chain that fires earlier than any single trigger alone. On the strict-Wyckoff Conservative profile, this combined sell chain cut the strategy's loss on the historical replay window from −17% to −6.4%. The buy side hadn't changed at all; the entire improvement came from how positions were closed.
8 · The phasor-state ledger
Every decision is logged with the full phasor state at the moment it was made: regime, θ, r, Re, Im, coherence, capital_flow, conviction, and the universe-wide environment. Entries, exits, and every Q4 crossing get their own row.
This is not a transaction log. It's a state log. A transaction log tells you what was bought and sold. A state log tells you what the market looked like when you made the decision, which is the only thing that lets you answer "was this a bad trade or bad luck?" months later. Attribution becomes a SQL query, not a reconstruction exercise.
9 · The nightly loop
Every night after the Market Phasor pipeline finishes, the agent runner executes in order:
- Environment — computes universe and portfolio coherence, classifies regime, sets risk budget.
- Resolve projections — checks pending projections from prior runs. Where the horizon has elapsed, computes residual (actual θ vs projected θ). Flags diverged positions for exit review.
- Signal (buy + sell) — pre-filters universe and portfolio. Buy signal now includes projection filter: rejects stocks whose projected conviction at H5 degrades. Sell signal adds "projected conviction degraded" trigger.
- Timing — decides which signals are ready to act this bar. Rejects entries where projected θ at H5 overshoots past 130°.
- Sizing — Kelly + coherence multiplier, respecting risk budget and per-position caps.
- Price — sets limit prices using the bar's volatility envelope.
- Ledger write + projection record — every decision is persisted with full state. New positions get a projection entry for the default horizon, so residual tracking starts immediately.
The loop is idempotent: rerunning the same bar produces the same decisions. The randomness comes entirely from upstream market data, not from the agents themselves. This is why the system is auditable and why the ledger is meaningful as training data.
10 · Phase extrapolation — trade on where it's going
The single biggest change to the system: we no longer trade on today's conviction. We trade on the projected conviction at a holding-period horizon.
The core shift
Before extrapolation, the signal agent asked: "what conviction is this stock in today?" Now it asks: "what conviction will this stock be in at my holding horizon?" This changes everything:
|
Before: trade on t
Buy hl today
Stock is in markup with positive hidden flow right now. Hope it stays there.
|
After: trade on t+k
Buy hl projected at H5
Stock is projected to still be in markup with conviction in 5 bars. The trajectory is confirmed, not hoped for.
|
How it works — the Taylor series
Brook Taylor (1715) showed that if you know a function and its derivatives at one point, you can reconstruct its value at nearby points:
First term = where you are. Second = velocity × time. Third = ½ × acceleration × time². More terms = further reach, but for smooth signals even two terms are enough over short horizons. This is the same tool Newton used for orbits, Euler used for differential equations, and every GPS chip uses between satellite fixes.
Every bar the phasor gives us three numbers:
θ(t)— the phase angle (position in the Wyckoff cycle)ω = dθ/dt— angular velocity (how many degrees per bar the cycle is advancing)α = d²θ/dt²— angular acceleration (is ω speeding up or slowing down, averaged over 20 bars)
Plugging into Taylor, truncated at second order:
Example: stock at θ=75°, rotating at ω=6°/bar, decelerating at α=−0.4°/bar². At H5: θ̂ = 75 + 30 − 5 = 100° (still in markup). At H10: θ̂ = 75 + 60 − 20 = 115° (approaching distribution). The signal agent sees this and adjusts accordingly — H5 is safe, H10 is a warning.
The same expansion applies to amplitude r(t) to project move strength. From (θ̂, r̂) we reconstruct (Re, Im) and run the same conviction classifier. The output is a projected conviction — hl, dw, pr, or he — at each horizon.
What changes in the agent chain
- Signal agent (buy) — after filtering for today's conviction = hl, applies a secondary projection filter: rejects any stock whose projected conviction at H5 degrades to
dworhe. Gives a +5 score bonus to stocks projected to stayhlwith high confidence. A stock that looks great today but is projected to enter distribution in 5 bars never reaches the buy list. - Signal agent (sell) — adds "projected conviction degraded" as a new review trigger. Positions held where projected conviction worsens to
dworheare flagged for exit review even if today's conviction is still healthy. - Timing agent — rejects entries where projected θ at H5 would overshoot past 130° (late markup). Today the stock may be at θ=75° (sweet spot), but if the extrapolation says it'll be at 140° in 5 bars, you're buying the tail end of the move.
Confidence gating
Not all projections are equally trustworthy. Confidence is derived from the stability of angular velocity:
A stock rotating at a steady 5°/bar has confidence near 1.0. One wobbling wildly has confidence near 0. The system ignores projections below 0.40 confidence — noisy extrapolations are treated as null, and the agent falls back to current-state logic only.
What this means for the portfolio
- Fewer false entries: stocks that look great today but are projected to degrade never enter the portfolio. The old system would have bought them and taken a loss.
- Earlier exits: the sell chain flags positions projected to degrade before the degradation actually happens. You trim while the stock still looks healthy on the surface.
- Early entries on pre-reversals: a stock in
pr(pre-reversal) today that's projected to enterhlin 5 bars is the early entry the old system missed entirely. Today it's "watchlist only." With projection, the timing agent can confirm it.
11 · Observe: residual tracking
Every projection is a hypothesis with an expiry. When the horizon arrives, the system compares:
This is the tracking loop — the same observe→act pattern used in Kalman filtering and model-predictive control. The system records a projection for every position at entry, then resolves it when the horizon elapses:
|
|residual| < 45°
Confirmed
Trajectory is holding. The hypothesis that put you in this trade is intact. Stay.
|
|residual| > 45°
Diverged
Trajectory broke. Something changed — news, capital rotation, sector shift. Review for exit.
|
The threshold (45°, one regime boundary width) is configurable via residual_exit_threshold in Config. Diverged positions are flagged in the activity feed and queued for the sell chain's next cycle.
Why this is backtestable by construction
Every bar in the historical per-ticker parquets has θ(t) and dθ/dt. At any past bar t, you can run the extrapolation, compare to actual θ(t+k), and compute the residual. No new data needed. The backtest script (src/backtest_extrapolation.py) does exactly this across the full IDX universe — conviction hit rates, residual distributions, confidence calibration — all from existing data.
12 · Live prices
The phasor pipeline runs nightly on end-of-day closes. But the Positions tab also fetches real-time intraday prices from Yahoo Finance (IDX tickers via the .JK suffix) every time you visit the page. This overlays live market value, P&L, and intraday change on top of the phasor-state view.
The live prices don't affect the phasor math (which runs on daily closes only) or the agent decisions (which run on the nightly snapshot). They're purely for portfolio monitoring — seeing your P&L move in real time while the phasor view tells you the structural story underneath.
14 · Coherence decomposition — finding order in chaos
The full IDX universe (1032 stocks) is always CHAOTIC. The solution: don't average the whole room. Find the group that's in agreement and trade that.
The N=1032 problem
The Kuramoto coherence C = |Σ eiθk| / N averages 1032 unit vectors. Hundreds of those are illiquid small-caps with tiny amplitude (r < 0.005) — they're not participating in any market move, they're just noise. Including them in the denominator dilutes the signal from the 400-500 stocks that are moving together. The coherence can never get above 0.35 because you're measuring "room consensus" in a room where half the people are asleep.
Leave-one-out decomposition
For each stock k, compute what happens to coherence when you remove it:
If positive: removing this stock increases coherence. It's fighting the consensus — a chaos source. If negative: removing it decreases coherence. It's contributing to alignment. This is O(N) to compute: subtract one vector from the total sum and recompute the magnitude.
Sector coherence
Chaos doesn't just come from individual stocks. Entire sectors can rotate anti-phase to the market. We compute coherence per sector (intra-sector alignment) and inter-sector coherence (do the sector median phasors agree?):
- High intra-sector, high inter-sector — everything aligned. TREND.
- High intra-sector, low inter-sector — sectors are internally coherent but rotating in different directions. ROTATION.
- Low intra-sector — even within sectors, stocks disagree. CHAOTIC.
Filtered universes
Instead of one coherence number, the environment agent now reports four:
| Universe | Filter | Typical coherence | Environment |
|---|---|---|---|
| Full | none (all 1032) | ~0.30 | CHAOTIC |
| Active | r ≥ 0.005 | ~0.35 | ROTATION |
| Sector-aligned | θ within 90° of sector median | ~0.69 | TREND |
| Active + Aligned | both filters | ~0.74 | TREND |
The buy chain gates on the Active + Aligned universe — typically 500-600 stocks, coherent enough for systematic trading. The other 400+ stocks aren't wrong; they're just not part of the current move.
Trading the coherent subset
The signal agent still sees all 1032 stocks in the snapshot. But the environment agent's coherence reading — and therefore the risk budget, Kelly cap, and buy_active gate — is based on the filtered universe. This means:
- The system trades in TREND environment with full risk budget, even though the "raw" market looks chaotic
- Individual stock filters (conviction, PI duration, Taylor projection) still apply — filtering the universe doesn't lower the bar for individual entries
- When even the aligned subset drops below 0.35 (actual market-wide stress), the system correctly stops
13 · Why our realtime is not look-ahead
The offline phasor pipeline uses scipy.signal.filtfilt (forward-backward Butterworth) and a full-series FFT Hilbert transform. Both silently read future data. The realtime view and the extrapolation do not.
The two culprits in the offline pipeline
When the nightly batch computes the phasor for bar t, two functions peek ahead:
filtfilt— applies the Butterworth filter forward, then backward. The backward pass at bartuses barst+1, t+2, …, N.scipy.signal.hilbert— uses a full-series FFT. Every output sample is a linear combination of every input sample, past and future.
This is standard best practice for offline signal analysis and produces the cleanest phase estimate. But the state it reports for bar t was influenced by data that didn't exist on day t.
The causal invariant
At the very last bar N of any series, there is no future to peek at — the series ends there. So:
The offline pipeline and the causal pipeline must produce exactly the same answer at the last bar. This is a mathematical identity, not an approximation. It holds to full floating-point precision.
What the realtime view computes
When you search a ticker in the Realtime tab, the system runs MarketPhasor(prices[:t+1]) for every bar t and keeps only the last row each time. This is the causal trail — at every bar, the phasor state a real-time observer would have computed with only the data available up to that moment:
Cost: O(N²) per stock. In practice ~200–400ms for 365 bars. Not the bottleneck.
Why the extrapolation is also look-ahead-free
The projected trail on the Realtime charts is computed from the last bar's state — the one bar where offline and causal are provably identical. The Taylor expansion uses only:
θ(N)— the phase at the last bar (causal-safe by the invariant above)ω = dθ/dtat barN— computed fromθ(N) − θ(N−1), both causal-safeα = mean(diff(dθ/dt))over the last 20 bars — eachdθ/dtvalue is the difference of two consecutive causal-safe thetas
No future data enters at any point. The projection is a function of the present and the recent past, extrapolated forward by calculus. The same Taylor expansion run on the causal trail would produce the same projected trail — because at bar N, they are the same number.
Position Intelligence — per-bar zone tagging
A regime label tells you what stage a stock is in. Position Intelligence tells you where in that stage it is. A stock can be in markup early, mid, late, or exhausted — and the right action differs sharply between those four sub-zones.
Why the regime label alone isn't enough
Two stocks both labelled "markup" can be in very different states:
- Stock A entered markup three bars ago. Hidden flow still rising. θ=85°. Early markup — the move is just starting.
- Stock B has been in markup for thirty bars. Hidden flow rolling over. θ=128°. Late markup — the move is nearly done.
The label says they're the same. The phasor's θ says they're miles apart. Position Intelligence formalises this distinction.
The four zones
| Zone | θ range within markup | Action |
|---|---|---|
early | 45° – 80° | Strongest entry candidate — full sizing. |
mid | 80° – 110° | Acceptable entry — half sizing or skip if late-zone exits are tight. |
late | 110° – 135° | No new entries. Existing positions queued for review. |
exhausted | 135° – 180° | Anticipatory-exit fires. Position closed regardless of price. |
The same four-zone breakdown applies to accumulation, re-accumulation, distribution, markdown, and capitulation — each with its own θ ranges anchored to the regime's typical θ window.
Per-bar causal computation
An early version of PI computed the zone only on the latest bar of the snapshot — useful for live, useless for backtest. The fix was to extend the per-ticker worker to compute PI causally on every bar:
The worker re-ran across the universe via extend_causal.js; the full pass took 1.4 minutes. After the extension, the backtest engine sees PI tags on every historical bar, and the entry / exit triggers that depend on them — pi_position_in_zone, pi_anticipatory_exit — fire correctly during replay, not just on today's snapshot.
The PI entry filter
When the buy chain runs, candidates are filtered by their current PI zone. Conservative requires pi_position_in_zone ∈ {early, mid}; Aggressive allows {early, mid, late}. A candidate that today looks great by every other metric but is sitting in exhausted zone is rejected before sizing.
Empirically, on the strict-Wyckoff Conservative profile, the PI entry filter cut the trade count from 97 to 7 — a 93% reduction. The remaining 7 trades had a far higher hit rate. The filter wasn't reducing the strategy's edge; it was eliminating the long tail of marginal entries that diluted the average.
The PI anticipatory-exit trigger
Open positions are scanned every bar. If the position's PI zone moves to exhausted AND the projected next regime is bearish (distribution, markdown, or capitulation), the anticipatory-exit flag fires and the sell chain closes the position at the next bar's open. This catches the regime drift that the label alone misses.
Live ↔ backtest parity — the deployment guarantee
A strategy that backtests well but lives differently is not a strategy — it's a hope. The PM engine is built around a strict guarantee: the same strategy, on the same data, produces the same trades whether run in backtest or live. Bit-for-bit, every signal, every size, every exit.
The two failure modes parity rules out
Live ↔ backtest divergence has two classic causes:
- Different code paths. The backtest uses one sizing function; live uses another that "handles edge cases." A bug in either silently skews results.
- Different inputs. The backtest reads from the snapshot; live reads from the streaming MCP. Subtle field-name or unit differences cause silent mismatch — a position is entered in backtest because
regimeis a string, but skipped live because the streaming format gave it as an enum integer.
Both failure modes are insidious because nothing crashes. The strategy keeps running. The numbers just don't agree.
The parity construction
The engine's solution is single-source: every decision-making function is defined once, in one file, and called by both the backtest harness and the live runner. There is no backtest_size() and live_size(). There is one size(), and it has no awareness of which harness called it.
| Component | Live | Backtest |
|---|---|---|
| Signal agent | Same module | Same module |
| Timing agent | Same module | Same module |
| Sizing agent | Same module | Same module |
| Reconcile loop | Same function | Same function |
| Ledger schema | Same parquet | Same parquet |
| Phasor inputs | Causal columns from snapshot | Causal columns from per-ticker parquet |
The only difference between the two harnesses is the source of the price feed: live reads tomorrow's bar from MCP at market close; backtest reads it from the historical parquet. Everything downstream of the price input is shared code.
The parity test
Every CI run replays the last 30 bars through both harnesses and asserts that the resulting trade ledgers are identical. Not approximately equal — identical. Same tickers, same days, same sizes, same prices. If a single field differs, CI fails and the build is blocked.
This is not a "nice to have" guarantee. It is the entire reason the backtest results above (the −17% → −7.8% Conservative improvement, the +2.6% Balanced fix) are quotable. If the backtest were not bit-for-bit identical to the live engine, those numbers would be marketing copy. With parity in place, they are predictions.
Trade-log diagnostics — surfacing causal failure modes
Most quant infrastructure gives you P&L. The PM ledger gives you P&L plus the full phasor state at every entry, every exit, every Q4 crossing, and every PI tag. That data lets a separate diagnostic pipeline answer the only question that matters when a strategy underperforms: why did this happen?
The three failure-mode categories
When a strategy loses money, the causes group into three:
- Bad signal. The phasor said "buy" and the price went down. The math was wrong, or the market is non-stationary in a way the model doesn't see.
- Bad timing. The signal was correct but the entry / exit fired at the wrong moment. PI was misclassified, or the sequential-sizing pass deferred the entry until the move was gone.
- Bad environment. The signal was correct, the timing was correct, but the macro environment moved against the trade. ROTATION turned to CHAOTIC mid-trade; correlated tickers all rolled over together.
Each category has different remedies. Conflating them is how a quant team chases their tail for months.
What the ledger captures per row
| Field | Meaning |
|---|---|
entry_regime / exit_regime | Did the regime change between entry and exit? If yes, regime drift was the culprit. |
entry_pi_zone / exit_pi_zone | Did PI correctly tag the entry as early/mid? If a winner was tagged "exhausted" at entry, PI is mis-calibrated. |
entry_environment / exit_environment | Did the macro environment change? TREND→CHAOTIC mid-trade is the canonical environment failure. |
pct_bullish_at_entry | Was the TREND-direction filter satisfied? If a loser entered with pct_bullish < 0.50 in TREND, the filter was missing or bypassed. |
imag_at_entry / imag_at_exit | Did hidden flow flip? An entry with imag > 0 that exited with imag < 0 is a textbook distribution-rollover loss. |
theta_at_entry / theta_at_exit | How far through the cycle did the trade run? Short θ-distance trades are losers; long θ-distance trades are winners. |
residual_at_exit | If a projection was registered, did the realized θ match the projected θ? Large residual = trajectory broke. |
The diagnostic queries
Three SQL queries, run automatically on every losing trade, classify the failure:
- Regime-drift loser:
entry_regime IN (accumulation, markup) AND exit_regime IN (distribution, markdown). Five of six losers in a recent Conservative window matched this — they entered in accumulation and the regime drifted within days. Remedy: tightenentry_regimes_allowedin ROTATION environments. - PI-misclassification loser:
entry_pi_zone = 'exhausted'on a position that was nominally early-markup. Remedy: review the PI computation for that ticker class. - Environment-shift loser:
entry_environment = 'TREND' AND exit_environment = 'CHAOTIC'. Remedy: tighten coherence floor or shorten holding-period intent.
The pipeline runs nightly against the full ledger, summarises the failure-mode mix per profile, and writes a one-page diagnostic to the activity tab. The next morning, the user sees not just "P&L was −1.2%" but "1.2% loss driven by 5 regime-drift exits, 1 environment shift, 0 PI misclassifications."
Where insights go back into the engine
The diagnostic isn't read once and discarded. Patterns that recur across multiple windows are converted into rule changes:
- The TREND-direction filter (
pct_bullish > 0.50) was added because regime-drift losers in TREND clustered on bars where bullish stocks were a minority. - The PI entry filter was tightened on Conservative because the bottom-of-zone entries (PI=late) had a 0% win rate in the regime-drift bucket.
- The drawdown ceiling was added because environment-shift losers showed multi-position cascade behaviour — the ceiling halts entries before the cascade compounds.