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,36 @@
---
id: TASK-26
title: 'propose compares stops against completed sessions only'
status: PR
assignee: []
created_date: '2026-10-14'
labels: []
dependencies: []
references:
- DRAFT-33
- TASK-20
ordinal: 26000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Seen 2026-10-09 01:24 Taipei (2026-10-08 13:24 ET, mid-NYSE-session): the actual-portfolio
`propose` pass fired `EXIT RVMD` because the "2026-10-08 session" price 185.43 was below the
187.00 stop. That number was yfinance's still-forming intraday bar; RVMD closed 188.12 that day
and never closed at or below 187 through 10-13. `core/book.py` `stopped_out` is close-based, so
the two rules disagreed about the same stop.

Change: each market profile (`core/screening/__init__.py`) carries its session close as
(timezone, HH:MM) — `us` America/New_York 16:00, `tw` Asia/Taipei 13:30. `_run_propose` drops
the panel's final row when that row is the market's today and the market's clock is before the
close, so every price `propose` reads (stop/EXIT, MA50, trail, scores) is a finished session.

Probe: `KUROSHIO_LEDGER_DIR=$(mktemp -d) PYTHONPATH=. .venv/bin/python -m kuroshio.cli propose --ips ~/.kuroshio/ips-us.md --holdings ~/.kuroshio/book/latest/actual/holdings_us.yaml --market us --no-ledger` · budget $0 · reps 1 — during the NYSE session the cards' session label is the previous trading day; after 16:00 ET it is today.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 with the clock stubbed at 13:24 ET on a session whose partial bar is below a holding's invalidation price, `propose` emits no EXIT for it and reads the previous session; at 16:30 ET the same panel fires the EXIT — a test each.
- [ ] #2 a TW market read at 21:00 Taipei keeps today's bar; a panel whose last row is not the market's today is never trimmed — tests with a stubbed clock, never the local one.
<!-- AC:END -->
24 changes: 23 additions & 1 deletion kuroshio/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from pathlib import Path

from kuroshio.core.screening import PROFILES, get_profile
from kuroshio.types import ENTRY_DATE_SOURCES, SETUP_TYPES, Candidate, Holding
from kuroshio.types import ENTRY_DATE_SOURCES, SETUP_TYPES, Candidate, Holding, Panel


def _parse_tickers(tickers: str | None, tickers_file: str | None) -> list[str]:
Expand Down Expand Up @@ -649,6 +649,7 @@ def _run_propose(
try:
provider = get_provider(provider_name)
panel = provider.fetch_panel(fetch_tickers, _lookback_days(profile, holdings))
panel = _completed_sessions(panel, profile)
except ImportError:
return None, [
f'error: the {provider_name!r} provider is not installed. '
Expand Down Expand Up @@ -681,6 +682,27 @@ def _run_propose(
return cards, None


def _completed_sessions(panel: Panel, profile, now: datetime.datetime | None = None) -> Panel:
"""The panel minus a still-forming final bar (TASK-26). A provider read during the
session returns today's partial bar as if it were a close — 2026-10-08 13:24 ET
fired an EXIT on RVMD's intraday 185.43 against a 187.00 stop it never closed
under, while ``core.book.stopped_out`` is close-based. So the final row is dropped
when it is the market's today and the market's own clock is before its close."""
from zoneinfo import ZoneInfo

# ponytail: fixed close time — an early-close half day keeps the bar out until the
# regular close, which only delays the check; add a holiday calendar if that matters.
tz, close_at = profile.session_close
now = (now or datetime.datetime.now(ZoneInfo(tz))).astimezone(ZoneInfo(tz))
index = panel.close.index
if len(index) and str(index[-1]) == now.date().isoformat() and now.strftime("%H:%M") < close_at:
panel = dataclasses.replace(panel, **{
f.name: frame.drop(index[-1], errors="ignore")
for f in dataclasses.fields(panel) if (frame := getattr(panel, f.name)) is not None
})
return panel


def _lookback_days(profile, holdings: list[Holding]) -> int:
"""The fetch window: the profile's, extended to reach the oldest ``entry_date`` in the
book plus the profile's own window as warm-up (MA50 and ATR14 need sessions *before*
Expand Down
5 changes: 4 additions & 1 deletion kuroshio/core/screening/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class MarketProfile:
# largest share of `final_score` one pctrank can control — see tw.MIN_RANK_WEIGHT
min_rank_weight: float
accepts_sector_map: bool = False
# (IANA timezone, HH:MM) of the regular session close — a bar dated the market's
# today is still forming before it (cli._completed_sessions)
session_close: tuple[str, str] = ("America/New_York", "16:00")


PROFILES = {
Expand All @@ -50,7 +53,7 @@ class MarketProfile:
# row after 65 trading sessions. No institutional-flow benchmark ticker.
"tw": MarketProfile(
"tw", tw.screen, tw.score_names, "finmind", 120, 200, 65, None,
min_rank_weight=tw.MIN_RANK_WEIGHT,
min_rank_weight=tw.MIN_RANK_WEIGHT, session_close=("Asia/Taipei", "13:30"),
),
}

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 @@ -19,3 +19,4 @@
{"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"}
{"task": "TASK-25", "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": ["mlk5R5fu48"], "cost_usd": 0}, "findings": {"HIGH": 0, "MEDIUM": 0, "LOW": 1}, "repaired": 0, "rejected": 0, "debt_logged": 1, "gate_failures": 0, "pr": 66, "gate": "ruff clean, pytest 491/491", "decision": "met"}
{"task": "TASK-26", "date": "2026-10-14", "orchestrator_checks": [{"checkpoint": "SPEC", "requested": "opus-level+", "effective": "claude-opus-5-5", "evidence": "session model id", "verified_at": "2026-10-14T15:00:00Z", "outcome": "confirmed"}, {"checkpoint": "VERIFY", "requested": "opus-level+", "effective": "claude-opus-5-5", "evidence": "unchanged session model id", "verified_at": "2026-10-14T15:30:00Z", "outcome": "confirmed"}], "review_calls": 1, "review_mode": "focused", "model_routes": [{"role": "implementer", "attempt": 1, "requested": "opus-level", "effective": "claude-opus-5-5", "evidence": "implemented in the orchestrator session at the user's direct request", "risk": "medium", "reason": "money path (stop/EXIT), user asked for the fix in-session", "outcome": "completed"}, {"role": "verifier", "attempt": 1, "requested": "opus-level", "effective": "opus", "evidence": "Agent model=opus, pr-reviewer mode review", "risk": "medium/focused", "reason": "call 1 always runs at the high-capability level", "outcome": "completed"}], "review_input_tokens": null, "review_output_tokens": null, "probe": {"runs": ["local-9498456-1420Z"], "cost_usd": 0}, "findings": {"HIGH": 0, "MEDIUM": 0, "LOW": 0}, "repaired": 0, "rejected": 0, "debt_logged": 0, "gate_failures": 0, "pr": 67, "gate": "ruff 0 · pytest 482/482 · 9498456", "decision": "met"}
1 change: 1 addition & 0 deletions tasks/reviews/pr67.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"result":"APPROVED","findings":[]}
70 changes: 70 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1466,3 +1466,73 @@ def test_propose_fetches_back_to_the_oldest_entry_date(tmp_path, capsys, monkeyp
]) == 0
capsys.readouterr()
assert stub.calls[0][1] >= 500


# --- TASK-26: stops compare against completed sessions only ---------------------


def _rvmd_panel() -> Panel:
"""RVMD on 2026-10-08: 188.12 the prior sessions, 185.43 on the still-forming bar."""
dates = [d.strftime("%Y-%m-%d") for d in pd.bdate_range(end="2026-10-08", periods=65)]
close = pd.DataFrame({"RVMD": [188.12] * 64 + [185.43]}, index=dates)
volume = pd.DataFrame({"RVMD": [1_000_000.0] * 65}, index=dates)
return Panel(close=close, volume=volume, institutional=None)


def _propose_rvmd_at(tmp_path, monkeypatch, now: str):
import functools
import zoneinfo

from kuroshio import cli

monkeypatch.setenv("KUROSHIO_LEDGER_DIR", str(tmp_path / "ledger"))
_use_stub(monkeypatch, _rvmd_panel())
clock = pd.Timestamp(now, tz=zoneinfo.ZoneInfo("America/New_York")).to_pydatetime()
monkeypatch.setattr(
cli, "_completed_sessions", functools.partial(cli._completed_sessions, now=clock)
)
holdings = tmp_path / "holdings.yml"
holdings.write_text(
'- {ticker: RVMD, weight: 0.05, score: 0.5, setup_type: value_dip, '
'entry_price: 200.0, invalidation_price: 187.0}\n'
)
cards, problems = cli._run_propose(
str(EXAMPLES / "ips-balanced.md"), str(holdings), "us", no_ledger=True
)
assert problems is None
return cards


def test_task26_mid_session_bar_does_not_fire_an_exit(tmp_path, monkeypatch):
"""AC #1: 13:24 ET on 10-08 — the 185.43 bar is not a close; the run reads 10-07."""
cards = _propose_rvmd_at(tmp_path, monkeypatch, "2026-10-08 13:24")
assert not [c for c in cards if c.action == "EXIT"]
assert all("2026-10-08" not in c.reason for c in cards)


def test_task26_after_the_close_the_same_bar_fires_the_exit(tmp_path, monkeypatch):
"""AC #1: 16:30 ET on 10-08 — the bar is the session close and the stop fires."""
cards = _propose_rvmd_at(tmp_path, monkeypatch, "2026-10-08 16:30")
assert [c.sell for c in cards if c.action == "EXIT"] == ["RVMD"]


def test_task26_completed_sessions_uses_the_market_clock(monkeypatch):
"""AC #2: TW at 21:00 Taipei keeps today's bar; a US panel whose last row is not
the market's today (01:00 Taipei = the prior US date) is never trimmed."""
import zoneinfo

from kuroshio import cli

dates = ["2026-10-07", "2026-10-08"]
panel = Panel(close=pd.DataFrame({"X": [1.0, 2.0]}, index=dates),
volume=pd.DataFrame({"X": [1.0, 1.0]}, index=dates))
taipei = zoneinfo.ZoneInfo("Asia/Taipei")
tw_evening = pd.Timestamp("2026-10-08 21:00", tz=taipei).to_pydatetime()
tw_morning = pd.Timestamp("2026-10-08 10:00", tz=taipei).to_pydatetime()
us_next_day = pd.Timestamp("2026-10-09 01:00", tz=taipei).to_pydatetime() # 13:00 ET 10-08
us_day_after = pd.Timestamp("2026-10-10 01:00", tz=taipei).to_pydatetime() # 13:00 ET 10-09

assert list(cli._completed_sessions(panel, get_profile("tw"), now=tw_evening).close.index) == dates
assert list(cli._completed_sessions(panel, get_profile("tw"), now=tw_morning).close.index) == dates[:1]
assert list(cli._completed_sessions(panel, get_profile("us"), now=us_next_day).close.index) == dates[:1]
assert list(cli._completed_sessions(panel, get_profile("us"), now=us_day_after).close.index) == dates
Loading