robertsben333-cmyk

earnings-edge-hunt

Seals what the market has already priced into each of the day's earnings prints, then sends one free-roaming hunter per company to find information that is not in that price, and sums their signed finding sizes into one number per company so the day's names can be ranked. Use when asked to run the edge hunt, rank the day's earnings names, hunt for unpriced information, or score companies reporting.

robertsben333-cmyk 0 1 Updated 4w ago
GitHub

Install

npx skillscat add robertsben333-cmyk/claude-research/earnings-edge-hunt

Install via the SkillsCat registry.

SKILL.md

Edge hunt — one signed number per company, so the day can be ranked

This stage produces, for every company reporting in the window, a single signed
number in points of spot: impact_sum, the hunters' own per-finding sizes added
up. Sort on it and the day is ranked. Beside it travels conviction (its absolute
value), which is where the direction skill lives.

There is no call, no threshold and no direction label anywhere in the output. That
is deliberate and it is the point: the question being tested is whether these
companies can be ranked at all
, and that question is answerable at every cut
only if nothing has already been rounded into a bucket upstream.

What changed on 2026-09-09, and why the arithmetic got simpler

researcher_us/EDGE_ANALYSIS.md decomposed six resolved runs — 43 names, 249 findings —
against the realised move, pooling within days. Every transformation the scorer
applied lowered the rank correlation:

ρ p
impact sum, as the hunters wrote it 0.407 0.017
× (1 − priced_in/100) 0.376 0.027
cluster-max 0.284
÷ √k 0.279
× agreement discount × quality multiplier → the old edge_score 0.243 0.156

A paired bootstrap over days puts the gap between the first row and the last at
+0.165, 95% CI [+0.082, +0.244], so this is not small-sample noise. The machinery is
still computed — into diagnostics, where residual_sum remains a genuinely open
question and edge_score_legacy keeps earlier runs comparable — but it decides
nothing.

The direction lives in the large numbers only. Over all 38 de-duplicated events
the sign of impact_sum is a coin flip: 53% to the next close. But the rank of
conviction predicts whether the sign was right at ρ=+0.514, permutation p=0.0015.
Above the median conviction the sign is right on 74% of events. |impact_sum| ≥ 3
gave 16/21 with +6.37% per trade. Run the same test on |edge_score| and it returns
−0.003 — the old machinery destroyed this too.

The stage has still not beaten a free control. -run_up_20d_pct, one number off
the sealed baseline before any subagent is spawned, ranked those six days at ρ=0.335
and was positive on 6 of 6 days when traded. edge_resolve.py now prints it beside
every result. Until the hunt beats it, the hunt has not been shown to add anything,
and the note must say so.

Why it looks like this

Categories destroyed the first run. On the first run of 2026-08-31 the adversary
returned one of three verdicts, baseline quality was one of three tiers, and hunters
returned up/down/abstain. Every judged finding landed in one of two verdict buckets
and twelve companies collapsed to one non-zero score and eleven zeros. There was
nothing to rank, and the bucketing did that rather than the evidence. Everything
that was a category is now a number.

Removing the categories worked, and revealed the next problem. The second run of
2026-08-31 produced eight rankable names with eight distinct scores — a strict order,
which is what the rebuild was for. But the whole table spanned less than
two-thirds of a point of spot, because an adversary judging all 51 findings on both
sides left very little standing. The stage now reliably produces an order; whether
that order carries information is what edge_resolve.py pooled across many days is
for, and one day of eight names cannot answer it.

Self-rated confidence does not work. Across 33 scored arm calls in
backtest/runs/pilot-40, the model's own evidence_quality split top-third and
bottom-third accuracy at exactly 50/50. No hunter's feeling about its own certainty
enters the score. researcher_us/scripts/edge_score.py derives the key from the hunters' signed
sizes and nothing else.

The crux is "is it already priced", and no rule settles it. The PWR event in
backtest/notes/disagreement-map.md had two analyses holding the same principle,
reading the same corpus, disagreeing about which story was the widely-told one, and
landing 14% apart. That was the case for an adversary, and for a year it was the design.
It did not survive measurement — see section 4 — so the question is now left inside the
hunter's own sizing rather than adjudicated after the fact.

If your Routine prompt disagrees with this file

This file wins. The prompt is a bootstrap: it knows the environment, the timing and
the agent counts, and it cannot know the output contract, which has changed once already
and will change again as days pool.

Concretely, if the prompt you were given says the output is "one signed number on
−100…+100", or asks you to report edge_score and confidence, you have the
pre-2026-09-09 prompt. Those fields are gone from the decision path. Do this:

  1. Rank and report on impact_sum and conviction, as sections 5 and 6 specify.
  2. edge-scores.json names its own ranking_key — quote that in your closing report so
    the disagreement is visible rather than silently resolved.
  3. Record one line in the run log: the Routine prompt is stale and the replacement text
    is in researcher_us/routine-prompts/edge-hunt.md, waiting to be pasted. A session cannot
    update that Routine itself — update_trigger refuses any Routine an agent did not
    create — so the note is the only way the fix gets requested.

Do not split the difference by emitting both. Two ranking keys in one note is worse than
either.

What is fixed and what is free

Fixed: the sealed baseline, the numeric output contracts, the sourcing rule, and
that every name in the universe is scored. Free: where hunters look, what they look
for, and how they reason. Do not add a research checklist — the nine-area program
belongs to stage 2 and importing it here rebuilds the thing this stage replaces.

0. Is there already a run in this directory?

Before writing anything, look:

ls <RUN>/edge/ <RUN>/edge/hunts/ <RUN>/edge/adversary/ 2>/dev/null

If a previous edge hunt has written here, archive it before you start — do not
merge into it and do not delete it:

mkdir -p <RUN>/edge/_run1-<session>/{baselines,hunts,adversary}
git mv <RUN>/edge/hunts/*.json <RUN>/edge/_run1-<session>/hunts/
git mv <RUN>/edge/adversary/*.json <RUN>/edge/_run1-<session>/adversary/
# plus that run's baselines, edge-scores.json and edge-note.md

edge_score.py reads baselines/, hunts/ and adversary/ non-recursively, so a
subdirectory is enough to hide the old run from it while keeping every file.

This is not hypothetical. Two edge hunts ran on 2026-08-31 over different
windows
— the first on that day's bmo with --include-unknown, the second on the
amc plus next-day bmo that --window resolves. Left in place, twelve names from
the first run would have been pooled into the second's ranking as one day. Worse,
their findings predated the continuous contract and carried direction rather than
expected_impact_pct, which the current scorer reads as 0.0 — so all twelve would
have entered the table as zeros and sorted above every genuinely negative name.
Write a README in the archive saying which window it covered and why it moved.

Two runs on one date is normal — a retry, a re-run against a corrected universe, or
a second window. Assume it has happened rather than assuming it has not.

0b. Sell yesterday's book, before anything else

Only when execution.enabled is true in config/pipeline.yaml. It is committed as
false, and a run that finds it false does nothing here and says nothing.

python3 researcher_us/scripts/alpaca_trade.py flatten --submit

Unless orders.exit_mode is not uniform. Then the flatten is wrong — it sells every
name at market when at least one of them wants an auction — and the sell is three calls.
The shipped mode since 2026-09-15 is amc_open: amc legs went into the opening auction
hours ago from the "Close AMC" Routine, and the bmo legs due today are yours to sell at
plain market, right here, before step 7 buys. That ordering is the point of the mode —
a bmo position still open when the entry is sized means gross exposure stacks.

python3 researcher_us/scripts/alpaca_trade.py verify --scan 'research/*/*/*/edge' --fix --submit
python3 researcher_us/scripts/alpaca_trade.py close --scan 'research/*/*/*/edge' --submit
python3 researcher_us/scripts/alpaca_trade.py status --scan 'research/*/*/*/edge'

verify goes first and its verdict lines go in the run log. A submitted sell is not a
sold position: six of the first seven auction exits either part-filled or filled nothing
and then expired, because cls and opg are eligible only in their one auction cross
and a $200k-a-day name has almost no size in it — HOFT 17 of 161, CODA 39 of 183, RLGT 0
of 224. verify --fix re-sends at plain market any leg the account still holds behind an
order that is dead at the broker, sized to what Alpaca reports is held. This run is the
only one of the day inside US market hours, so it is the only one that can.

close picks the instrument per position, and what it can reach depends on the mode.
Per trade over the 38 de-duplicated events, on the conviction book:

mode amc bmo per trade needs
uniform market ~10:00 ET market ~10:00 ET +4.49% (t=2.52) nothing; it is the flatten
bmo_close market ~10:00 ET today's close (cls) +6.27% (t=3.34) flatten_before_entry: false
auction_split opening auction (opg) today's close (cls) +7.81% (t=4.01) that, and a second Routine

bmo_close is reachable from this session and is where the cheap money is: +1.77pp of
the +3.32pp on offer, with no machinery that does not already exist. The cls window is
open until 15:50 New York, so a 16:04 Amsterdam start has hours.

auction_split is not reachable from here. Alpaca rejects rather than queues an opg
order between 09:28 and 19:00 ET, so nothing firing in the European afternoon can sell an
amc position into its own opening auction. That leg is the separate 14:00 Amsterdam
Routine in researcher_us/routine-prompts/edge-execute.md, which only a person can create.

Either way close also sends anything overdue — exit date already past — at plain
market immediately, and step 6b's open refuses to enter a new book while a position is
overdue and unsold. So a missed exit costs the measured exit and one loud refusal; it
never leaves a book nobody is managing.

First, not last. Two reasons, and the second is the one that matters. Every
position in the account has already been through its print, so nothing is cut short of
its event; and if this session dies mid-hunt — which has happened to other stages on
four consecutive days — the account is in cash rather than holding a book nobody is
managing. Flattening at the end would make a killed session leave yesterday's
positions open indefinitely.

It costs something. The measured exit is the next close (ρ=+0.514, permutation
p=0.0015); selling half an hour into the session is nearer the next open, which
measured weaker on the same events (ρ=+0.331, p=0.046).

Record in the run log what was sold and at what unrealised P&L, before the sweep
launches. That line is the only record of the exit, and there is nothing else in the
repo that will reconstruct it.

If the account is unreachable, say so in the run log and carry on with the hunt.
The research is the point; the book is downstream of it.

1. Universe and sealed baseline

python3 scripts/run_paths.py <YYYY-MM-DD> --json          # never invent a path
python3 researcher_us/scripts/edge_universe.py --window -o <RUN>/edge/universe.json
python3 researcher_us/scripts/priced_in.py --tickers <T,...> --date <D> --session <s> -o <RUN>/edge/baselines/

--window resolves today's amc plus the next trading day's bmo, which is the
pipeline's real window and what a daily routine wants. --date/--session is for
re-measuring one specific slice. Names in the window can span two dates and two
sessions, so priced_in.py needs one call per (date, session) group.

One issuer is one event. edge_universe.py folds a second share class into its
issuer before a baseline is sealed — LEN.B into LEN — and records what it folded in
share_classes_folded. Do not hunt the folded row and do not add it back: the two
classes share every fundamental, so a second hunt buys a correlated row, a second
baseline and, if both clear the floor, two legs of one bet. edge_score.py repeats the
check on the scored rows, so a run that reaches it with both keeps the thin class's
hunt on file but out of the ranking. --keep-share-classes exists for the day someone
deliberately wants to measure the pair; it is not for production. On 2026-09-16 both
were hunted and ranked, and the run log had to warn the resolver in prose.

Drop time-not-supplied rows unless you are deliberately re-measuring the phantom
rate — on the first 2026-08-31 run eight of eight of them had no earnings event
at all. Withholding --include-unknown on the second run took the phantom rate to
zero of ten.

time-not-supplied is Nasdaq's own time field. It is not a claim that the event is
fake, only that Nasdaq does not know the session, and on most days it is the larger
half of the calendar: 20 of 22 rows on 2026-09-17, which left one name in the
window
. The drop is right on the base rate and expensive on a thin day, so when the
window comes back under ten names, check the dropped rows instead of taking the base
rate on faith:

python3 researcher_us/scripts/edge_universe.py --window --include-unknown \
        -o <RUN>/edge/universe-with-unknown.json
python3 researcher_us/scripts/session_resolve.py --universe <RUN>/edge/universe-with-unknown.json

It reads two free sources and spends no agent. EDGAR kills a row outright when the
company filed its results in the ten days before the event — no quarterly or
semi-annual reporter prints twice in a fortnight — and that kill is certain. Nasdaq's
press-release feed
confirms a row when the company itself announced a results date in
the window, with a citable URL, and separately records whether the company announces its
dates at all: a missing announcement is only evidence against a row for a company that
has issued them before.

announced is precise and it is not sensitive. On the twelve labelled rows of
2026-08-31 it kept 4 of 4 real reporters and none of the 8 phantoms. On 2026-09-17
it found nothing, and TRT — which has never pre-announced a date in twenty years of
EDGAR history — was real, cleared the floor at +5.10 and was the day's only trade. So
--announced-only is for a day with more confirmed names than hunters, never for a
thin one
, and the filing-cadence prior is to be read rather than filtered on. It is
not a phantom test: two phantoms read fits on 08-31. It is a reason to look, and on
09-17 TRT / unresolved / fits / last item-2.02 2026-05-14, 126 days was the whole case
for the name that a sweep had already refuted on an incomplete filing history.

What it cannot do is settle the session. Nasdaq serves the release body as a
JavaScript shell and the headline usually omits the time, so a carried row reaches the
sweep with session_unresolved: true and the sweep has to settle it from a company
source or drop the name. That is not optional politeness: an unknown session is a coin
flip on whether the print is inside the window at all, because a bmo row dated today
printed this morning and an amc row dated tomorrow prints a full session after the
entry.

--apply rewrites the universe in place. On 2026-09-17 the script killed two rows
outright (HUBG, VFS), confirmed none by press release and carried eighteen; a hand check
the same evening found one of those eighteen — TRT — was real, and it became the day's
only position. The measured phantom rate for that window is 19 of 20, against 8 of 8
on 2026-08-31. The rule to withhold --include-unknown still holds on cost, nineteen
hunters to find one name, but it is not free and on 09-17 it cost the only name in the
book. Record the check in the run log either way; a checked thin day and an unchecked one
are different facts.

If the universe is empty, log it and stop cheaply. A holiday or a thin day is a real
answer.

The baseline can be wrong about whether the event exists

priced_in.py infers an earnings cadence from 6-K/8-K text matches, and for a
foreign private issuer it can catch monthly operational updates instead — bitcoin
production, vehicle deliveries. It now returns unknown with
cadence_implausible: true when the inferred cadence is under 70 days, because no
quarterly (~91) or semi-annual (~182) reporter has one. Before that guard existed,
one day produced four wrong verdicts: CANG at a 16-day "cadence" and NIO at 10, HMR
at 49 and PXS at 51.

That mattered because edge_score.py sets rankable = false on a suspect verdict
and multiplies baseline_quality by 0.05, so three companies whose dates were
confirmed by their own press releases were arithmetically incapable of ranking
anywhere.

If the sweep confirms a date from a company source and the baseline still disagrees,
amend it before any hunter launches, from company sources only:

python3 researcher_us/scripts/edge_baseline_amend.py --dir <RUN>/edge/baselines            # dry run
python3 researcher_us/scripts/edge_baseline_amend.py --dir <RUN>/edge/baselines --apply

This does not breach the sealing rule: event_plausibility is an event-existence
flag, not a priced-in estimate, and confirming or killing the event is the sweep's
stated job. The original verdict is kept in amended_from.

Amend symmetrically or not at all. The same pass that upgraded three names from
suspect also downgraded NIO from fits_cadence to unknown, because NIO's verdict
rested on the identical defect and its eight recorded "reactions" were reactions to
delivery reports. Correcting only the names that would score better is how a scorer
gets quietly tuned toward a result.

Commit the baselines before launching anything. A baseline written after a
finding exists is one the finding has contaminated. Then heartbeat:

python3 scripts/run_log.py --date <D> --heading "Edge hunt — <D> <session> — STARTED" --line "<plan>"
scripts/publish.sh "edge hunt: started for <D>"

2. Sweep — one agent, all names

Launch one edge-sweep agent for the entire universe. It confirms which
companies are really reporting and gives each survivor a continuous
hunt_priority plus a note on where an unpriced finding might live.

This exists because the first run sent twelve names to twelve deep hunters and
eight of them burned a full Opus/high budget establishing that no event existed.
One cheap agent replaces those eight.

Write to <RUN>/edge/sweep.json. Publish it.

3. Hunt — one agent per name, all of them

Launch unpriced-hunter on the confirmed names, ordered by hunt_priority:

One hunter per name, and every confirmed name gets one. The double hunt on the top
two was removed 2026-09-09: over six runs it paired twelve names and the gap between the
two hunters predicted neither the eventual error (+0.203) nor whether the sign was right
(+0.028), at n=12. Two slots for no signal.

What it did establish, before it was switched off, is that the key is noisy at its own
scale: median gap 2.40 points, and four of those twelve pairs came back with opposite
signs
. Nothing measures that any more. Treat it as a standing property of every
ranking, and re-run a double-hunt week occasionally rather than letting the number rot.

Give each hunter exactly: ticker, company, event date and session, the absolute
path to its baselines/<TICKER>.json, its output path, and its row from
sweep.json. Nothing else — not your view, not the other names, not the other
hunter.

The hunter reads researcher_us/LESSONS.md after it has sized the day once, not before;
its own definition sets that order and the sweep row carries trades_on and
short_interest for it. It returns the draft it had before it opened the file as
pre_lessons and says in lessons_applied what the file moved. That is the control on
the guidance itself: edge_score.py carries diagnostics.impact_sum_pre_lessons and
edge_resolve.py reports spearman_pre_lessons beside the key, so a file that only
adds plausible rules is caught doing it. A hunt that comes back without pre_lessons
gets a flag on its row and a line in the run log — do not reconstruct one. Since
2026-09-15 a hunt returns two numbers per name rather than one — print_vs_bar_pct
(what the number does against the bar) and expected_move_pct (what the stock does)
— plus lands_on and resolves_by per finding, bar, positioning_check, and an
outside_window list for sourced findings dated after the exit that must not enter
the key. The first two are resolved separately by edge_postmortem.py; the gap
between them is the cheapest test the stage has of whether the reaction is being
predicted or only the print. A hunt that comes back without them is under the old
contract: score it, and record in the run log that the agent definition in the tree
was not the one the hunter ran.

Where the sweep set baseline_history_trustworthy: false, or the baseline carries
cadence_implausible, tell that hunter so explicitly. Otherwise it will use a
reaction history built from monthly operational updates as an earnings base rate.

Publish after each wave, not at the end of the fan-out.

Do the budget arithmetic before the first launch

1 sweep  +  1 hunter per confirmed name
1 sweep  +  1 hunter per confirmed name

There is nothing else in the arithmetic any more. The cap of 20 buys nineteen hunted
names
, and config/pipeline.yaml carries that as hunted_names.

Shed per budget.edge_degrade_order, which sheds names: unconfirmed first, then
lowest hunt_priority. There is nothing left to shed but names — one hunter each is
already the floor. The single thing never shed at any budget is the sealed-baseline
ordering, because a baseline written after a finding exists is one the finding has
contaminated.

Keeping a name but measuring it worse corrupts the ranking silently. Dropping one
puts it in the table as rankable: false with a stated reason, where the reader can
see it. Prefer the visible loss.

One judgement call worth stating: shed by hunt_priority but keep the names with
a live option chain
, even if they rank low. On 2026-08-31 MDT (priority 26) and NIO
(37) both sat below the median and were kept deliberately, because they were the only
two names of ten with a usable chain and therefore the only ones where "what the
market priced" was measured rather than inferred from a historical median. A ranking
built entirely on names with no options is measuring something weaker. Record the
deviation in the run log.

Concurrency is not the same limit as budget. max_concurrent_subagents is about
load; the cap of 20 is about spend. An amc name reporting tonight must be hunted before
its release, so serialising strictly can push a hunter past the event — exceed the
concurrency guideline for those and stay inside the total. The 2026-09-09 run found the
real platform ceiling to be 8 concurrent subagents, not the 4 in config; launches beyond
it are rejected and cost nothing, so relaunch as slots free rather than planning around
the config number.

4. There is no adversary pass

Removed 2026-09-09. It returned two numbers and both were measured as subtractive over
215 findings on six resolved days.

size_check_pct — summing the hunter's own size ranks at ρ=0.453 against 0.407 for the
mean of the hunter and the adversary.

priced_in_pct — every way of letting it touch the ranking makes the ranking worse, and
monotonically in how much it is allowed to remove:

the key sums ρ
every finding at the hunter's size — as shipped +0.453
× (1 − priced_in/100) +0.325
findings with priced_in ≥ 90 dropped +0.407
≥ 80 dropped +0.328
≥ 70 dropped +0.221
only priced_in ≤ 50 kept +0.305

Mean priced_in per name ranks at +0.046. And once impact_sum became the key, neither
number reached the output at all — the pass was spending eight of twenty subagents on
something that changed nothing ranked, scored or resolved.

What was knowingly given up. The adversary was the only thing in the stage that
caught findings which were factually wrong: on 2026-09-09 a covenant amendment misread
by a year, and a "the short base has not moved" claim contradicted by its own source.
Those now enter the key unchecked. If a hunter's findings start looking unreliable, that
is the cost showing up, and the fix is the hunter prompt — not a checker with no path to
the output.

.claude/agents/priced-in-adversary.md, researcher_us/scripts/edge_brief.py and
researcher_us/scripts/edge_adversary_brief.py are still in the tree, unused, so the pass can be
re-run deliberately over a week if the priced-in question is ever reopened. edge_score.py
still reads adversary/ when it is present, and reports diagnostics.adversary_judged
as a count so a run with a pass and a run without are told apart at a glance.

5. Score and rank

python3 researcher_us/scripts/edge_score.py --run <RUN>/edge

Writes edge-scores.json: every name with impact_sum (the ranking key, signed
points of spot), conviction, priced_lean_pct (the control), hunter_view (the
hunter's two answers, carried whole), flags (reasons to doubt the row, from
researcher_us/LESSONS.md — one-off share, a print/reaction sign split, a sum the hunter's own
expected_move_pct does not support, an unsourced bar, a negative with no
short-interest check, findings netted rather than resolved), outside_window, and a
diagnostics block. The script prints which names clear conviction_floor from
config/pipeline.yaml. Nothing new enters the key.

Names whose event was not confirmed carry rankable: false and sit out of the
ranking rather than sorting to the top on a 0.

Do not filter this file and do not apply a cutoff — not even the conviction floor.
The floor governs emphasis in the note, never the contents of the JSON, because
the ranking test needs the complete table at every k.

Nothing in diagnostics is a decision input. If you find yourself reaching for
confidence or baseline_quality to decide what to highlight, stop: they were
measured at −0.090 and +0.074 against the realised move, and on 2026-08-31 the
highest-confidence name was the uninformative one while the name carrying that day's
entire correlation had the lowest confidence in the run.

5b. V2, beside V1 — seconds, and it may fail

python3 researcher_us/scripts/edge_grounded_score.py --run <RUN>/edge || true

Writes edge-scores-grounded.json next to edge-scores.json and changes nothing in
it. V1 is the key that ranks the note and the only key the book is placed on;
alpaca_trade.py never reads the grounded file. V2 maps each finding through the
shadow ledger's κ and the name's realised σ (researcher_us/README_V2.md). Until the
ledger holds edge_v2.min_n measured observations the file says uncalibrated and
carries no grounded numbers — that is the correct output, not a failure. If the
script errors, log it and carry on: nothing downstream of today waits on V2.

Run it before the first print, or not at all. V2 is compared forward only, so the
script refuses to write once the run's earliest print (a bmo name's 09:30 ET open, an amc
name's 16:00 ET close) has passed, and prints V2 not written. A resumed or late session
records that in the run log; it does not work around it.

6. The note

<RUN>/edge/edge-note.md, answer first: the ranked table, then for each of the top
and bottom names the finding driving it, its URL, and what the price already says.
Then the names that could not be ranked and why. End with the disclaimer from
config/pipeline.yaml.

The ranked table carries eight columns, always

column what it is
ticker
session amc or bmo, with the event date — the print is not today for every row
pre-lessons impact_sum_pre_lessons: the hunters' sum before they read LESSONS.md
post-lessons impact_sum, the ranking key: signed, points of spot. The only score the book trades on
V2 impact_sum_grounded, percent of spot, or uncalibrated while the shadow ledger is below edge_v2.min_n
floor does conviction clear conviction_floor
tradable yes, elsewhere, no or unknown — see below — with turnover, liquidity (ok / thin) and, for a short, whether Alpaca lends it
control -run_up_20d_pct

The table is ordered by post-lessons, because that is the key. Take the three score
columns from edge_grounded_score.py's printed table (step 5b) rather than copying
numbers by hand; it prints all three side by side from the files on disk. Print
uncalibrated in the V2 column instead of leaving it out: a column that disappears
on thin days reads as a V2 that stopped running. And say in one line under the table
that pre-lessons and V2 are measured beside the key, not traded.

Generate the session and tradable columns rather than assembling them by hand:

python3 researcher_us/scripts/alpaca_trade.py assets --run <RUN>/edge

It is read-only, places nothing, and is not gated on --submit or on
execution.enabled — so a run with execution switched off still gets both columns.

Session belongs in the table because the window spans two dates and two sessions.
--window resolves today's amc plus the next trading day's bmo, so a reader
scanning a single ranked list is otherwise looking at rows whose prints are up to a
day apart without being told. It also matters downstream: edge_resolve.py measures
the move over the session recorded, and exit_mode: auction_split sends amc and
bmo to different instruments. Mark any name whose session the sweep could not
source as unsettled rather than printing a session it only inferred.

Tradable is a separate question from the floor, and the note must not merge them.
The floor is a selection decision about whether the sign means anything; capacity and
borrow are facts about the name. A name below the floor may be perfectly tradable and
a name at the top of the table may be untradeable, so collapsing the two hides the one
thing a reader most needs. assets answers only "could this be traded" — turnover
against min_dollar_volume_usd, and, for a negative row, whether Alpaca will lend it.

On 2026-09-14 those columns were the finding: all four floor-clearing NEGATIVES were
refused by the book — COE and BIOX not lendable at Alpaca, HYFT below the turnover
floor — so the traded book was long-only and tested the long half of the ranking only.
A five-column table showed a clean 9-name ranking and did not show that at all.

Borrow at Alpaca is not tradability. assets keeps two facts apart. Liquidity is a
property of the name and holds at every broker: ok, thin (under
thin_dollar_volume_usd, $1m/day by default — the proxy for a broker's limited-liquidity
warning, which the operator does not trade into), or below floor. Borrow is one
broker's answer on one day, and a short Alpaca will not lend is routinely borrowable at
IBKR. So a short that clears the turnover floor but is not lendable here prints
elsewhere, not no, and the note says "check borrow at IBKR" rather than
"untradeable". COE read borrowable the morning after it was refused. Only below floor
and no 20-day volume are no.

The critical read of the floor-clearers

The floor says where the sign has meant something on the pooled sample. It does not say
a given row is a good trade, and the note's job is to be harder on the floor-clearers
than the table is. For every name that clears the floor, the note carries a short
paragraph that answers, from the hunt file itself:

  • Which line does it land on? A row whose size is mostly one_off is a row the
    market has repeatedly refused to re-rate on; say so. A guidance row is the kind that
    has paid.
  • Do the hunter's two numbers agree? print_vs_bar_pct against expected_move_pct.
    Opposite signs, or a reaction under half the sum of the findings, means the hunter's
    own caveats did not reach the key; quote the conviction_note.
  • Was the bar sourced, and what does positioning say? bar and
    positioning_check. An unsourced bar or a negative into a crowded short is a reason
    to call the row weak whatever its size.
  • Is it thin? A thin name's headline return is an upper bound on what size could
    have got; say it will carry a limited-liquidity warning.
  • What is in flags? Print them. They exist so a reader does not have to open the
    hunt JSON to find the reason to doubt a tidy number.

End that section with a plain sentence per floor-clearer: recommended, recommended
with a named reservation
, or not recommended, because …. That sentence changes no
file and no order — the book rule in alpaca_trade.py is unchanged and does not read it
— but it is what a reader takes away, and the resolved days show the floor alone
recommending the day's two disasters as readily as its two best trades. Be as hard on
the rows you like as on the rows you do not.

Borrow is a snapshot, so take it from the run's own record. Alpaca re-checks
shortability daily: COE was recorded shortable: false by the 2026-09-14 plan and
read borrowable the next morning. assets therefore prefers alpaca-plan.json over a
live lookup and prints which rows are the run's own answer and which were asked just
now; --live forces the current answer when the question really is "can I borrow it
today". A note regenerated later against a live lookup would claim the book could have
shorted a name the run had already refused, and would contradict the run log beside it.

What the note must also say

Each of the following, every time — each is here because a reader once drew a wrong
conclusion from a correct table. (Do not restate how many there are: the count was
"four" for months while the list held seven, which is the same duplicated-fact rot
that cost this repo five days of a stale stage table.)

Separate the order from the sign. Report the ranking for every name, and mark
which names clear the conviction floor. Say in the note that below the floor the sign
is a coin flip on the evidence so far (53% over 38 events) and that above it the rank
of conviction predicted sign-correctness at ρ=+0.514. A reader who treats a
impact_sum of −0.4 as a bearish view is reading the table wrong, and the note is
where that is prevented.

Also state that impact_sum is not a forecast of the move. The same fact often
appears in two findings from two sources and adding both double-counts it — which is
exactly what the cluster-max was built to stop, and the cluster-max is what the
measurement demoted. The number ranks; it does not size.

Give the control its own line. Print -run_up_20d_pct beside the ranking and say
whether the hunt's order differs from it. On the six resolved runs that free number
ranked at ρ=0.335 against the hunt's raw 0.407, a gap whose CI spans zero. A note
that reports the ranking without the control is claiming more than the evidence
carries.

Report the sign balance. Count how many hunts leaned each way and say so. Six of
eight leaned negative on 2026-08-31, which is more plausibly an artefact of asking
hunters to find what the market has missed into a print than a fact about those eight
companies. If it recurs across many days, the hunter prompt is generating pessimism
rather than detecting it — and that is only visible if each note records the count.

Say that nothing checked the findings. There is no adversary pass and no second
hunter, so a factually wrong finding enters the key at full size and nothing in the run
would have caught it. That is the accepted cost of nineteen names, and a reader should
not have to open the config to learn it.

Say that the key is not reproducible to better than its own size. When the stage
still double-hunted, twelve paired names came back with a median gap of 2.40 points and
four of the twelve had opposite signs, on a key whose typical magnitude is about 5.
Nothing re-measures that now, so quote it as a standing caveat rather than letting a
tidy ranking imply a precision it does not have.

Say how much of the baseline was measured rather than inferred. Count the names
with a live option chain. Where there is none, priced_lean_pct falls back to
−0.05 × the 20-day run-up, and the "expected move" in the baseline is a historical
median rather than a priced expectation. Seven of ten names had no listed options on
2026-08-31, and 18 of the first 43 resolved names took the fallback. Nothing
multiplies by that lean any more, but edge_resolve.py still normalises by the
expected move, so a note that calls the normalised correlation an implied-move
measure is overstating it.

Say what the day would have cost to trade. Capacity is not in the scorer and not
in the budget. The tradable column now carries spot × 20-day average volume per
name, so this bullet is no longer about producing the number — it is about saying out
loud what the column implies: how many names of the day are actually reachable, and
whether the extremes of the ranking are among them. On
2026-09-02 the single best-ranked name, DLTH, moved +23.20% on $170k a day of
turnover; six of the first 22 long/short positions traded under $1m a day. A ranking
whose extremes are untradeable is a research result, not a signal, and the note
should be the place a reader learns which one they are looking at.

6b. Buy today's book, after the research is done

Same switch as step 0b: nothing here happens unless execution.enabled is true.
This step is not a degradation and not a decision the session gets to make.

python3 researcher_us/scripts/alpaca_trade.py plan --run <RUN>/edge
python3 researcher_us/scripts/alpaca_trade.py open --run <RUN>/edge --submit --no-flatten
python3 researcher_us/scripts/alpaca_trade.py status --run <RUN>/edge

--no-flatten because step 0b already did it, hours ago. Without the flag open
would try to close positions that are not there, which is harmless but writes a
misleading line into the log.

Entries are plain market orders, filled while you watch, so there is no pending
order and no auction to beat. The only deadline is that the US session has to still
be open
— 16:00 New York, 22:00 Amsterdam in summer, earlier on US half-days. A run
that fires at 16:04 Amsterdam and finishes its hunts by 19:00 has three hours of
margin; the 2026-09-09 run took 2h53m end to end. A session that has been retried,
resumed or has run long may have none, and open refuses on its own rather than
sending an order into a closed market. That refusal is correct: record it and stop.

Buying at market instead of in the closing auction was measured on the same 18 traded
events — market-on-close 15/18 and +5.86% a trade, a market order at 14:00 ET 14/18
and +5.82%, a gap of four hundredths of a point (researcher_us/scripts/edge_entry_timing.py).
What that cannot see is the spread, and the auction is the deepest liquidity of the
day. So record the fills: status prints filled_avg_price per order, and if
those come back materially worse than the plan's notional, say so in the run log.
orders.entry: market_on_close in config/pipeline.yaml puts it back in the auction.

The selection is one rule — |impact_sum| >= conviction_floor, side from the sign —
plus a turnover floor and a shortability check. Do not widen it, do not hand-pick a
name into the book, and do not trade a name below the floor because its finding reads
well: below the floor the sign is a coin flip and that is the entire reason the floor
exists. The book is equal weight: every name gets the same dollars regardless of
its score. Do not size by conviction — the key ranks and does not size.

open sells every existing position at market before it places anything, so one
invocation a day is the whole operation. Entries are market-on-close and Alpaca stops
accepting MOC ten minutes before the bell, so it has to run on the entry date. A
session firing at 16:04 has time; a session that has been running for six hours may
not, and a plan built after that close says so rather than filling at the wrong price.

Record in the run log how many names met the benchmark, how many orders went in, and
every refusal with its reason. researcher_us/EXECUTION.md is the whole contract, including
what the rule does and does not rest on.

6c. Grow the shadow ledger — after the book, never before it

Only once step 6b has placed (or refused) the book, so V2 can never cost the entry
its margin. Skip the whole step if edge_v2.enabled is false or the session is
short of time; a skipped day loses nothing that tomorrow cannot collect.

python3 researcher_us/scripts/edge_shadow_engine.py collect --from-run <RUN>/edge
python3 researcher_us/scripts/edge_shadow_engine.py brief -o <RUN>/edge/shadow-brief.json

If the brief lists any inputs, launch one shadow-scorer agent with only the
brief path. It is outside the 20-subagent hunt budget (edge_v2.shadow_scorer_agents)
and it is the first thing shed. It has no web tools and must read nothing but the
inputs named in the brief: a score written after seeing the price is the one error
this ledger cannot detect. Do not hand it today's hunts, the note or your view.

python3 researcher_us/scripts/edge_shadow_engine.py ingest
python3 researcher_us/scripts/edge_shadow_engine.py measure
python3 researcher_us/scripts/edge_shadow_engine.py fit
python3 scripts/update_index.py
scripts/publish.sh "stage E V2: shadow ledger for <YYYY-MM-DD>"

measure only prices items that already carry a score and re-prices those whose
horizons were still open, so running it daily is what fills the 5d and 1m columns.
Put one line in the run log: collected, scored, refused (with the reason ingest
printed), and the pooled n at session_close.

7. Resolve, once the window closes

python3 researcher_us/scripts/edge_resolve.py --run <RUN>/edge
python3 researcher_us/scripts/edge_resolve.py --pool 'research/2026/*/*/edge'   # the real number

Reports, per day and pooled: the rank correlation between the key and the realised
move; the conviction-versus-sign correlation, which is the threshold-free form of
"is the direction real"; both free controls; the legacy key for continuity; and the
long/short spread. Where a run carries a calibrated edge-scores-grounded.json it
also reports V2 (spearman_v2_grounded), V1 × σ with no κ
(control_vol_only) and the calibration slope of each. V2 has added something only
where it beats the vol-only control; beating V1 alone can be pure volatility.

The pooled figure pools within days — each day converted to within-day ranks and
centred — because concatenating raw pairs across days lets market-wide drift into the
rank structure and understates every ranker (0.189 against 0.243 on the same six
days).

Watch the conviction number as days accumulate. It needs no cut and no calibration,
which is what makes it the honest headline; the ranking correlation and the
long/short spread both depend on where you cut. edge_resolve.py also splits hit rate
and payoff by the sign of the prediction, overall and above the floor, because one day
went 10/12 on shorts and 4/9 on longs and the next went 6/6 on longs; whether unpriced
good news is the weaker edge is only answerable pooled.

The post-mortem, finding by finding

Resolving the key says whether the day ranked. It does not say why a row was right or
wrong, and the resolved days show the two coming apart constantly: the fact right and
the reaction wrong. So after a run resolves, score it finding by finding:

python3 researcher_us/scripts/edge_postmortem.py --run <RUN>/edge --template   # writes finding-verdicts.json to fill
python3 researcher_us/scripts/edge_postmortem.py --run <RUN>/edge              # scores it
python3 researcher_us/scripts/edge_postmortem.py --pool 'research/2026/*/*/edge'

The template lists every finding with three fields to fill from the release and the
tape: fact_correct (did the thing the hunter said would be in the print appear),
reaction_correct (did the stock move the way the finding's sign said, over the
window), and moved_on (which line the move actually landed on, in the lands_on
vocabulary). The pooled report gives the fact/reaction confusion matrix, hit rate by
lands_on, and the resolution of print_vs_bar_pct against expected_move_pct. When
a pattern in that table recurs, it goes into researcher_us/LESSONS.md; that file is how the
next hunter learns it, and nothing else is.

One day is an anecdote. A single day of five to twelve names cannot produce a
meaningful correlation, and the pooled figure across many days is the result. Say
so in every note rather than letting a good first day read as a finding.

Standing rules

Never fabricate a number. Every company-specific figure carries a source URL or is
marked unavailable — a missing anchor correctly lowers the score, an invented one
corrupts the ranking.

Dates come off the URL path, not the search snippet. Search results relabel old
articles with the current year, and on 2026-08-31 three separate leads on one name
turned out to be August 2025 stories served as August 2026.

The sweep's session_confirmed is not event_confirmed. A confirmed date with an
unconfirmed hour does not say which session carries the reaction, and
edge_resolve.py measures the move over the session you record. Two names that day
were company-confirmed bmo while nearly all their prior prints were amc, and one
had a date with no sourceable hour.

Coverage tracks market cap. backtest/FINDINGS.md §27 measured news coverage as a
near-monotonic function of size, so a day of large caps flatters the hunt relative
to the small, high-change-expectation names the pipeline normally shortlists.

If an agent type comes back "not found", the definitions were written this session
and are not registered yet. Launch general-purpose with the agent file's body
pasted into the prompt, add a line forbidding it to read anything else under
edge/, and record in the run log that the run used the inlined form.