SD book

13. Timing: own it, and when to buy it

I'm working on SD itself · 5/6 I run Thatha Stocks · 1/2

9 min read

A long-term investor asks SD two questions, and they are not the questions SD was built for:

  1. Will this stock be higher in six months or a year?
  2. I'm going to buy it anyway. Can I get it cheaper if I wait a few weeks, and how likely is that?

Chapter 8 showed that SD cannot call direction over days or weeks better than "always up". Chapter 3 showed that SD is good at predicting how big the next move will be. This chapter puts both facts to work. One command answers both questions, and a walk-forward test reports how much of each answer is real skill.

cargo run --release --bin sd_timing -- AAPL MSFT                         # today's plan
cargo run --release --bin sd_timing -- AAPL --certainty 0.9 --within 10  # stricter, sooner
cargo run --release --bin sd_timing -- --input picks.csv --symbol-column symbol --out plans.csv
cargo run --release --bin sd_timing -- research                          # the evidence; refits timing_model.json

Saved evidence: reports/timing_research.txt. Code: src/timing.rs (the library, with rustdoc) and src/bin/sd_timing.rs (the research and the command).

#Question 1: will it be higher in six months or a year?

The model is the same logistic regression chapter 8 used, on the same measurement families (direction.rs): momentum, trend, volatility, relative strength, market weather, VIX, season, and SD read on daily, weekly and monthly bars. The targets change to higher in 126 bars and higher in 252 bars.

Every year from 2008 on, each candidate model is trained only on outcomes known before 1 January and graded on that year. Which candidate to use in a year is decided by its record over the five years before.

HorizonWalk-forward hit rate"Always up"AUCBrier (model / drift)
126 bars (6 months)66.6%66.6%0.490.2236 / 0.2238
252 bars (1 year)71.7%71.7%0.480.2050 / 0.2049

The honest reading: the long-term probability is the drift. About two stocks in three were higher six months later, and seven in ten a year later. No feature family improved on that out of sample. For the one-year horizon the walk-forward rule ships "drift only", so every stock gets the same 71%. For six months it ships momentum + trend + volatility + relative strength. Those probabilities are well calibrated (calls of 70–75% went up 71.5% of the time), but they rank stocks no better than chance.

Why ship a number that is the same for every stock? Because it is the true answer, and a precise-looking answer that ranks stocks wrongly would be worse. The number still means something: about 71%, not 95%, and not 50%. That is the right expectation for a one-year hold.

#Should SD tell you to sell a holding?

We tested the obvious rule: "sell when six-month P(up) drops below a threshold". On 2019+, walk-forward, the names it flagged went on to do better than the names it kept:

Sell belowShare flaggedFlagged: next 6 monthsKept: next 6 months
0.624.1%+23.7%+8.7%
0.6630.1%+14.6%+7.1%

By decile, the lowest-P(up) names returned +20% and the highest +4.6%. The model learned "after big, volatile runs, expect less", and 2019–2026 rewarded exactly those names. So the sell verdict is off by default (TimingConfig::sell_below_p126 = 0). You can turn it on to experiment, but the evidence says it would have hurt.

#Question 2: can I buy it cheaper if I wait?

#The setup

You will buy within N trading days no matter what. You can buy now at today's close, or place a limit order lower down, good for N days. If price trades through your limit, you pay the limit. If it never does, you buy at the close on day N.

The limit is a bet. A deep limit saves more when it fills, but it fills less often, and a miss usually means the stock went up, because that is why it never came back down.

#Measuring "how low will it go?"

For every symbol-day in 25 years we record the lowest low over the next N days, in units of the stock's normal movement:

z_min = ln(lowest low over days 1..N ÷ today's close) ÷ (σ₂₀ · √N)

This is where SD earns its place. A coiled stock with a high expected move dips further than a quiet one, so we keep a separate distribution of z_min for each of 30 buckets: three squeeze states (coiled, fired, quiet) × ten intensity deciles. Each bucket stores 101 quantiles, the median day of the low, and, for 14 certainty levels, the average effective entry: the limit if it filled, the day-N close if not.

  • The limit for certainty c is the c-quantile of z_min. History traded at or below it c of the time.
  • P(fill) at any price is the share of the bucket's history that got there.
  • The planner searches windows up to --within and certainties of at least --certainty, and picks the plan with the lowest expected effective entry. If no plan beats buying now by at least 0.25%, it says buy now, and it still shows what the plan you asked for would cost.

A limit counts as filled only when the low trades 0.1% through it, because just touching your price does not mean your order was reached.

#Results (tables learned before 2019, graded on 2019+)

SD makes the fill odds honest. Across the 30 buckets, the fill probability SD's tables promise misses the realized fill rate by:

WindowSD tablesOne table ignoring SD
5 days1.2 points4.6 points
21 days1.6 points5.5 points
63 days3.0 points6.3 points

When SD says "70% chance to fill", it fills about 70% of the time in every kind of market state. A single, SD-blind table is right only on average. It is too optimistic for quiet stocks and too pessimistic for coiled ones.

But waiting does not pay, on average, for a stock you will buy anyway:

WindowCertaintyFilledEffective entry vs buying nowWhen filledWhen missed
50.769.1%+0.36%−1.14%+3.81%
210.770.4%+0.74%−2.40%+8.61%
210.989.3%+0.54%−0.58%+10.45%
630.771.2%+1.57%−3.93%+16.57%

(Positive means you paid more than buying at today's close.) Fills save real money, 2.4% on a 21-day limit. But the 30% of misses cost 8.6%, because they are exactly the stocks that ran away. The result is the same in every squeeze state, and for the names with the highest six-month P(up). So the planner says buy now on almost every day. It found a limit worth placing on 3.8% of days at the loosest settings, and those limits did worse (+1.1%). Today, all 200 Thatha names say "buy now".

Why build a timer that mostly says "buy now"? Because that is the answer the evidence gives, and the timer is what found it. Without it, a limit 2% below the close "feels" free. With it, you can see that the limit costs about 0.7% on average, and you get an honest P(fill) if you want to wait anyway, say for cash that arrives in two weeks.

#Exits are the mirror, and there waiting helps

Sell limits use the highest high, z_max, in the same buckets. For a position you will sell within N days anyway, waiting with a limit helps on average, because the drift that punishes buyers rewards sellers. This holds from 21 days up; at 5 days it is a wash:

WindowCertaintyFilledEffective exit vs selling now
50.769.7%−0.04%
210.769.1%+0.37%
630.769.5%+1.72%

SD's buckets keep sell-limit fill odds within 1.0–1.2 points (6 points for an SD-blind table). Since the sell verdict is off by default, the exit plan for a holding is shown as a reference take-profit level, with its P(fill).

#Reading a plan

AAPL: 2026-10-30 close 344.63 | P(up) 6m 70% / 1y 71% | SD 0.750 (coiled, intensity 100%)
      buy:  BUY NOW: no limit good for ≤21 days with ≥70% fill odds beat buying now by 0.25% on average;
            if you still wait, a limit at 338.67 (1.73% below) fills 70% of the time but averages +0.65%
            vs buying now, misses included
            typical low 332.85 around day 9 of 21
      own:  HOLD; for reference, a take-profit at 353.33 (2.53% above) within 21 days filled 70% of the time
            typical high 360.20 around day 13 of 21
KnobFlagDefaultMeaning
Certainty--certainty0.7lowest acceptable fill probability for a buy limit
How soon--within21longest wait, in trading days (tables: 5, 10, 21, 42, 63)
Long-term floor--min-p0.5skip names whose one-year P(up) is below this
Worth waiting--min-saving0.0025a limit must save this much on average
Exit certainty / window--exit-certainty, --exit-within0.7, 21the same for sell limits
Sell verdict--sell-below0 (off)six-month P(up) below which a holding is marked "sell"

From code, a single call does all of it:

use portfolio_analyzer::timing::{plan_for, TimingConfig};
let plan = plan_for(&bars, &spy, &vix, &TimingConfig::default()); // Option<TimingPlan>

#How Thatha Stocks uses it

The daily pipeline runs sd_entry_timer right after the portfolio selector. For each name in the taxable and IRA selections, it writes June_Portfolio/outputs/entry_timing_1m.csv and entry_timing_ira_200k.csv (and .json), which contain:

  • the action, limit, window and P(fill)
  • the typical low
  • six-month and one-year P(up)
  • a reference take-profit

investor_app/scripts/build_data.py merges these into every selection row of portfolio-data.json (entryAction, entryLimit, entryFillProbability, pUp1y, …). Settings come from environment variables: SD_TIMING_CERTAINTY, SD_TIMING_WITHIN, SD_TIMING_EXIT_CERTAINTY, SD_TIMING_EXIT_WITHIN.

Thatha's crate carries a copy of SD under src/sdcore/, under its own namespace so Thatha's older indicators.rs and sd.rs are untouched. After changing SD here, run scripts/sync_sdcore.sh in the Thatha repository.

#Recipe: improve the timer

  1. Change the buckets (bucket_of in timing.rs). For example, split "fired" by phase, or add a market-weather dimension.
  2. Run sd_timing research. Compare the calibration table and the planner table with the saved report. A change earns its place only if it improves the 2019+ numbers, never by looking better on the years it learned from.
  3. Rebuild (the model is embedded with include_str!), and resync Thatha.

Pitfalls.

  • The fill rule ignores gaps through the limit. A gap down fills at the open, which is better than the limit, so results are slightly conservative.
  • Dividend-adjusted history makes old prices smaller but leaves z unchanged, because it is a ratio.
  • A stock with under ten years of history gets neutral monthly-SD inputs.