13. Timing: own it, and when to buy it
I'm working on SD itself · 5/6 I run Thatha Stocks · 1/2
A long-term investor asks SD two questions, and they are not the questions SD was built for:
- Will this stock be higher in six months or a year?
- 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.
| Horizon | Walk-forward hit rate | "Always up" | AUC | Brier (model / drift) |
|---|---|---|---|---|
| 126 bars (6 months) | 66.6% | 66.6% | 0.49 | 0.2236 / 0.2238 |
| 252 bars (1 year) | 71.7% | 71.7% | 0.48 | 0.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 below | Share flagged | Flagged: next 6 months | Kept: next 6 months |
|---|---|---|---|
| 0.62 | 4.1% | +23.7% | +8.7% |
| 0.66 | 30.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
cis thec-quantile ofz_min. History traded at or below itcof the time. - P(fill) at any price is the share of the bucket's history that got there.
- The planner searches windows up to
--withinand 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:
| Window | SD tables | One table ignoring SD |
|---|---|---|
| 5 days | 1.2 points | 4.6 points |
| 21 days | 1.6 points | 5.5 points |
| 63 days | 3.0 points | 6.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:
| Window | Certainty | Filled | Effective entry vs buying now | When filled | When missed |
|---|---|---|---|---|---|
| 5 | 0.7 | 69.1% | +0.36% | −1.14% | +3.81% |
| 21 | 0.7 | 70.4% | +0.74% | −2.40% | +8.61% |
| 21 | 0.9 | 89.3% | +0.54% | −0.58% | +10.45% |
| 63 | 0.7 | 71.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:
| Window | Certainty | Filled | Effective exit vs selling now |
|---|---|---|---|
| 5 | 0.7 | 69.7% | −0.04% |
| 21 | 0.7 | 69.1% | +0.37% |
| 63 | 0.7 | 69.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
| Knob | Flag | Default | Meaning |
|---|---|---|---|
| Certainty | --certainty | 0.7 | lowest acceptable fill probability for a buy limit |
| How soon | --within | 21 | longest wait, in trading days (tables: 5, 10, 21, 42, 63) |
| Long-term floor | --min-p | 0.5 | skip names whose one-year P(up) is below this |
| Worth waiting | --min-saving | 0.0025 | a limit must save this much on average |
| Exit certainty / window | --exit-certainty, --exit-within | 0.7, 21 | the same for sell limits |
| Sell verdict | --sell-below | 0 (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
- Change the buckets (
bucket_ofintiming.rs). For example, split "fired" by phase, or add a market-weather dimension. - 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. - 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
zunchanged, because it is a ratio. - A stock with under ten years of history gets neutral monthly-SD inputs.