Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
id: TASK-24
title: 'book honors the ratcheted stop in stops.jsonl'
status: PR
assignee: []
created_date: '2026-10-14'
labels: []
dependencies: []
references:
- TASK-18
ordinal: 24000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
The book's stop veto (`kuroshio/core/book.py`, `stopped_out` and the "below its stop" check in
`walk`) only knows the rating's `stop_loss`. The actual-portfolio pass ratchets held names' stops
upward by ATR and appends each move to the ledger's `stops.jsonl` (`ledger.STOPS`, rows
`{date, market, ticker, old, new, reason}`), and fires EXIT on that ratcheted line. The book never
reads it, so a name the owner just exited on the ratcheted stop stays in (or comes back to) the
book the next day. Observed on the owner's 2026-10-13 book: a held name whose stop the ratchet
had raised well above its rating's `stop_loss` closed under the ratcheted line, got an EXIT card,
and stayed in the book at full weight.

Change: the stop the book vetoes on is the stop **in effect on each session**:
`max(rating stop_loss, newest stops.jsonl `new` for that ticker and market whose row date is on
or after the rating date and strictly before that session)`. A close at or below the stop in
effect that session is a stop-out → the existing skip `"{rating} stopped out {date}; needs a new
rating"` (the existing phrase stays; it may append the stop value). Today's "below its stop" check
uses the stop in effect today. Rows dated before the rating date are ignored, so a newer rating
re-arms the name exactly as today. A close that was above the stop in effect on its own day is
never a stop-out retroactively, even if the stop was later ratcheted above it.

`cmd_book` loads `ledger.ledger_dir() / ledger.STOPS` best-effort, filtered to `--market`, the
same way `_logged_stops` does (an unreadable ledger warns on stderr and the book builds as
before); `build_book` takes the rows as a new optional argument (`None` → today's behavior).

Not changed: sizing (`target_weight` still sizes on the rating stop), the `invalidation_price` the
book writes to holdings.yml, propose, the ratchet itself, stops.jsonl writes, the vendored
`kuroshio/agents/engine`.

Probe: `T=$(mktemp -d); cp -R ~/.kuroshio/ledger $T/ledger; mkdir -p $T/book/2026-10-12; cp ~/.kuroshio/book/2026-10-12/book.json $T/book/2026-10-12/; KUROSHIO_LEDGER_DIR=$T/ledger PYTHONPATH=. <main-checkout>/.venv/bin/python -m kuroshio.cli book --market us --screen ~/.kuroshio/screen/2026-10-13.json --ratings ~/.kuroshio/ledger/ratings.jsonl --scores ~/.kuroshio/ledger/scores.jsonl --ips ~/.kuroshio/ips-us.md --meta ~/.kuroshio/book/meta.json --pm-size ~/.kuroshio/book/pm_size.json --locked ~/.kuroshio/book/locked.json --themes ~/.kuroshio/book/themes.json --provider yfinance --lang zh --out $T/book/2026-10-13; echo rc=$?; grep -o '[A-Z]* *[A-Za-z]* \(below its stop\|stopped out\)[^"]*' $T/book/2026-10-13/alloc.md $T/book/2026-10-13/book.md | head` · budget $0, one yfinance fetch of ~80 names, under 90 s · reps 1 — rc=0, and the name exited on its ratcheted stop on 2026-10-12 is skipped (`below its stop` or `stopped out`) at the ratcheted level, in neither core nor attack. Real holdings: the PR states the outcome only.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 a test in tests/test_book.py on a fictional ticker: rating stop 80 (rated 2026-09-05), stops rows ratcheting to 110 on 2026-10-02, close 109.5 on 2026-10-12 → the name is skipped `stopped out 2026-10-12; needs a new rating`; the same fixture without stops rows keeps the name (today's behavior).
- [ ] #2 tests: a close above the stop in effect on its day but below a later ratcheted stop is not a stop-out; stops rows dated before the rating date are ignored (a newer rating re-arms the name); rows of another market or ticker are ignored.
- [ ] #3 `kuroshio book` reads stops.jsonl from the ledger dir; an unreadable or missing file warns (or is silent when absent) and the book still builds; pre-existing tests pass unedited.
<!-- AC:END -->
10 changes: 10 additions & 0 deletions kuroshio/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,7 @@ def _stop_history(screen_path: str, market: str, provider_name: str | None, ttl_

def cmd_book(args: argparse.Namespace) -> int:
from kuroshio.core import book as bookmod
from kuroshio.core import ledger
from kuroshio.core.ips import parse_ips

rules = bookmod.BookRules(
Expand All @@ -1163,12 +1164,21 @@ def cmd_book(args: argparse.Namespace) -> int:
for k, v in leverage_map.items()
):
raise ValueError(f"{args.leverage_map}: expected a flat UNDERLYING: ETF_TICKER map")
# Best-effort like _logged_stops: a missing/unreadable trail must not prevent
# building the book with the rating stops it already knows.
try:
stops = [r for r in ledger.load(ledger.ledger_dir() / ledger.STOPS)
if r.get("market") == args.market]
except OSError as exc:
print(f"warning: stop ledger read failed: {exc}", file=sys.stderr)
stops = []
book = bookmod.build_book(
screen,
bookmod.load_jsonl(args.ratings),
parse_ips(args.ips),
meta=bookmod.load_json(args.meta),
scores_rows=bookmod.load_jsonl(args.scores),
stops_rows=stops,
positions=bookmod.load_positions(args.positions),
nav=args.nav,
pm_size=bookmod.load_json(args.pm_size),
Expand Down
43 changes: 32 additions & 11 deletions kuroshio/core/book.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
`Underweight` and unrated names are dropped. With a scores ledger (`scores_rows`) a rating
also dies at the first `fundamentals.next_earnings_date` that falls after it: the print the
rating did not see. Without one, only the day TTL applies.
A name whose screen close is at or below its rating's `stop_loss` is dropped too: the
A name whose screen close is at or below its effective stop is dropped too: the
thesis that rating priced is invalidated until a newer rating resets the stop. With a
price history (`closes`) that holds for any close since the rating date, not just today's:
a name that broke its stop and closed back above it the next day stays out until it is
Expand Down Expand Up @@ -154,11 +154,23 @@ def previous_book(root: Path, asof: str) -> dict | None:
return json.loads((dirs[-1] / "book.json").read_text()) if dirs else None


def stopped_out(history: dict | None, rating_date: str, asof: str, stop: float) -> str | None:
"""The first session after `rating_date` (through `asof`) that closed at or below `stop`,
from a {date: close} history; None when there was none or no history was given."""
def stop_in_effect(stop: float | None, rows: list[dict], rating_date: str, session: str) -> float | None:
"""Rows are date-sorted for one ticker/market. A ratchet is known only after its close;
a new rating re-arms the name, and its own stop remains the floor."""
row = next((r for r in reversed(rows) if rating_date <= r["date"] < session), None)
return max(stop, row["new"]) if row and stop is not None else row["new"] if row else stop


def stopped_out(
history: dict | None, rating_date: str, asof: str, stop: float | None,
stops_rows: list[dict] | None = None,
) -> str | None:
"""The first session after `rating_date` (through `asof`) that closed at or below the
stop then in effect; a later ratchet must never invalidate an earlier close."""
return next((d for d, c in sorted((history or {}).items())
if rating_date < d <= asof and c is not None and c <= stop), None)
if rating_date < d <= asof and c is not None
and (level := stop_in_effect(stop, stops_rows or [], rating_date, d)) is not None
and c <= level), None)


def build_book(
Expand All @@ -168,6 +180,7 @@ def build_book(
*,
meta: dict | None = None,
scores_rows: list[dict] | None = None,
stops_rows: list[dict] | None = None,
positions: list[dict] | None = None,
nav: float | None = None,
pm_size: dict | None = None,
Expand All @@ -194,6 +207,13 @@ def build_book(
if r.get("market", market) == market and r.get("rating") and r.get("date", "") >= min_date:
ratings[r["ticker"]] = r

# Keep each name's dated trail, not just its latest level: historical closes must be
# judged against the stop known that day. Stable sorting keeps the last same-day move.
stops: dict[str, list[dict]] = {}
for r in sorted(stops_rows or [], key=lambda r: r["date"]):
if r.get("market") == market:
stops.setdefault(r["ticker"], []).append(r)

next_earnings: dict[str, str] = {}
for r in scores_rows or []:
f = r.get("fundamentals") or {}
Expand Down Expand Up @@ -303,13 +323,14 @@ def walk(tier: frozenset) -> tuple[list[dict], list[dict], list[tuple]]:
skipped.append((row["rank"], t, ind, rating if rat else "not researched"))
continue
entry, stop = row["factors"]["close"], rat.get("stop_loss")
if stop is not None and entry <= stop:
# the rating's own invalidation is breached: its thesis is dead until a new
# rating says otherwise. Before this, target_weight read stop >= entry as "no
# stop" and gave the name full base weight.
skipped.append((row["rank"], t, ind, f"{rating} below its stop {stop:.2f}"))
trail = stops.get(t, [])
level = stop_in_effect(stop, trail, rat["date"], asof)
if level is not None and entry <= level:
# The veto follows the ratchet; sizing and the written invalidation still
# belong to the rating, so keep `stop` unchanged for target_weight below.
skipped.append((row["rank"], t, ind, f"{rating} below its stop {level:.2f}"))
continue
if stop is not None and (hit := stopped_out((closes or {}).get(t), rat["date"], asof, stop)):
if hit := stopped_out((closes or {}).get(t), rat["date"], asof, stop, trail):
skipped.append((row["rank"], t, ind, f"{rating} stopped out {hit}; needs a new rating"))
continue
w, cap = target_weight(entry, stop, t in tier, t in pinned & held_overflow
Expand Down
1 change: 1 addition & 0 deletions tasks/pr-loop-ledger.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
{"task": "TASK-21", "date": "2026-09-14", "orchestrator_checks": [{"checkpoint": "SPEC", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-14T15:55:00Z", "outcome": "confirmed"}, {"checkpoint": "IMPLEMENT", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-14T15:55:00Z", "outcome": "confirmed"}, {"checkpoint": "VERIFY", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-14T15:55:00Z", "outcome": "confirmed"}], "review_calls": 1, "review_mode": "focused", "model_routes": [{"role": "implementer", "attempt": 1, "requested": "sonnet-level", "effective": "sonnet", "evidence": "Agent model=sonnet", "risk": "ordinary", "reason": "one new writer beside holdings_yaml, tightly specified", "outcome": "completed"}, {"role": "verifier", "attempt": 1, "requested": "opus-level", "effective": "opus", "evidence": "Agent model=opus, pr-reviewer", "risk": "low/focused", "reason": "call 1 always at the high-capability level", "outcome": "completed"}], "review_input_tokens": null, "review_output_tokens": null, "probe": {"runs": ["local c992c76 x1"], "cost_usd": 0}, "findings": {"HIGH": 0, "MEDIUM": 0, "LOW": 0}, "repaired": 0, "rejected": 0, "debt_logged": 0, "gate_failures": 0, "gate": "ruff 0 \u00b7 pytest 375/375 \u00b7 c992c76", "decision": "met"}
{"task": "TASK-22", "date": "2026-09-15", "orchestrator_checks": [{"checkpoint": "SPEC", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T09:30:00Z", "outcome": "confirmed"}, {"checkpoint": "IMPLEMENT", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T09:30:00Z", "outcome": "confirmed"}, {"checkpoint": "VERIFY", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T09:30:00Z", "outcome": "confirmed"}, {"checkpoint": "REPAIR", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T09:30:00Z", "outcome": "confirmed"}, {"checkpoint": "RE-VERIFY", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T09:30:00Z", "outcome": "confirmed"}], "review_calls": 2, "review_mode": "full", "model_routes": [{"role": "implementer", "attempt": 1, "requested": "sonnet-level", "effective": "sonnet", "evidence": "Agent model=sonnet", "risk": "ordinary", "reason": "template table + lang plumbing, tightly specified; English byte-for-byte guarded by the unedited suite", "outcome": "completed"}, {"role": "verifier", "attempt": 1, "requested": "opus-level", "effective": "opus", "evidence": "Agent model=opus, pr-reviewer", "risk": "high/full", "reason": "call 1 always at the high-capability level; analyzer risk high", "outcome": "completed"}, {"role": "implementer", "attempt": 2, "requested": "sonnet-level", "effective": "sonnet", "evidence": "SendMessage resume of the same sonnet implementer", "risk": "ordinary", "reason": "one-template repair with a named repro", "outcome": "completed"}, {"role": "verifier", "attempt": 2, "requested": "sonnet-level", "effective": "sonnet", "evidence": "Agent model=sonnet, pr-reviewer mode verify", "risk": "low/delta", "reason": "delta verification of a 74-line repair", "outcome": "completed"}], "review_input_tokens": null, "review_output_tokens": null, "probe": {"runs": ["local 0ecc1a1 x1", "local f35376d x1"], "cost_usd": 0}, "findings": {"HIGH": 1, "MEDIUM": 0, "LOW": 0}, "repaired": 1, "rejected": 0, "debt_logged": 0, "gate_failures": 0, "pr": 46, "gate": "ruff 0 · pytest 413/413 · f35376d", "decision": "met"}
{"task": "TASK-23", "date": "2026-09-15", "orchestrator_checks": [{"checkpoint": "SPEC", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T12:30:00Z", "outcome": "confirmed"}, {"checkpoint": "IMPLEMENT", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T12:30:00Z", "outcome": "confirmed"}, {"checkpoint": "VERIFY", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T12:30:00Z", "outcome": "confirmed"}, {"checkpoint": "REPAIR", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T12:30:00Z", "outcome": "confirmed"}, {"checkpoint": "RE-VERIFY", "requested": "opus-level+", "effective": "claude-opus-5", "evidence": "host session model declaration (Opus 5)", "verified_at": "2026-09-15T12:30:00Z", "outcome": "confirmed"}], "review_calls": 2, "review_mode": "focused", "model_routes": [{"role": "implementer", "attempt": 1, "requested": "sonnet-level", "effective": "sonnet", "evidence": "Agent model=sonnet", "risk": "ordinary", "reason": "zh template rewrite + two name tables", "outcome": "completed"}, {"role": "verifier", "attempt": 1, "requested": "opus-level", "effective": "opus", "evidence": "Agent model=opus, pr-reviewer", "risk": "low/focused", "reason": "call 1 always at the high-capability level", "outcome": "completed"}, {"role": "implementer", "attempt": 2, "requested": "sonnet-level", "effective": "sonnet", "evidence": "SendMessage resume of the same implementer", "risk": "ordinary", "reason": "test coverage repair with named repros", "outcome": "completed"}, {"role": "verifier", "attempt": 2, "requested": "sonnet-level", "effective": "sonnet", "evidence": "Agent model=sonnet, pr-reviewer mode verify", "risk": "low/delta", "reason": "delta verification", "outcome": "completed"}], "review_input_tokens": null, "review_output_tokens": null, "probe": {"runs": ["local fd616ed x1", "local a333884 x1"], "cost_usd": 0}, "findings": {"HIGH": 0, "MEDIUM": 2, "LOW": 0}, "repaired": 2, "rejected": 0, "debt_logged": 0, "gate_failures": 0, "pr": 47, "gate": "ruff 0 · pytest 421/421 · a333884", "decision": "met"}
{"task": "TASK-24", "date": "2026-10-14", "orchestrator_checks": [{"checkpoint": "SPEC", "requested": "opus-level+", "effective": "claude-opus-5-5", "evidence": "host session model declaration", "verified_at": "2026-10-14T15:18:29Z", "outcome": "confirmed"}, {"checkpoint": "VERIFY", "requested": "opus-level+", "effective": "claude-opus-5-5", "evidence": "host session model declaration", "verified_at": "2026-10-14T15:18:29Z", "outcome": "confirmed"}], "review_calls": 1, "review_mode": "focused", "model_routes": [{"role": "implementer", "attempt": 1, "requested": "codex high-capability", "effective": null, "evidence": "codex:codex-rescue default routing", "risk": "medium", "reason": "money-path book logic; user asked for codex", "outcome": "completed; commit blocked by codex sandbox, orchestrator committed"}, {"role": "verifier", "attempt": 1, "requested": "opus-level", "effective": "opus", "evidence": "Agent model=opus", "risk": "low/focused", "reason": "call 1 at high capability", "outcome": "completed"}], "review_input_tokens": null, "review_output_tokens": null, "probe": {"runs": ["eFyGPyRts1"], "cost_usd": 0}, "findings": {"HIGH": 0, "MEDIUM": 0, "LOW": 0}, "repaired": 0, "rejected": 0, "debt_logged": 0, "gate_failures": 0, "pr": 65, "gate": "ruff clean, pytest 494/494", "decision": "met"}
1 change: 1 addition & 0 deletions tasks/reviews/pr65.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"pr":65,"task":"TASK-24","call":1,"result":"APPROVED","findings":[]}
Loading
Loading