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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ different scales.

Record *why* you own a position — `setup_type:` (value_dip | pullback_add | trend_add |
other) plus `entry_price:` and, for the dip setups, `invalidation_price:` — and `propose`
watches each one on its own terms: a `trend_add` is alerted when it closes under its 50-day
mean, a `value_dip` never is (looking weak against its MAs is the setup) and is alerted only
watches each one on its own terms: a `trend_add` is alerted when it closes more than 3% under
its 50-day mean, a `value_dip` never is (looking weak against its MAs is the setup) and is alerted only
when it closes at or below the invalidation price you recorded. Add an `entry_date:` and the
stop moves with the tape: a `trend_add` — and a `pullback_add` once its running high has
cleared entry + 2R — trails its invalidation price at `caps.trail_atr_mult` ATR14s below the
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ Pure function. v1 logic:
letting cash absorb the difference.
3. **Thesis monitoring**, dispatched on `setup_type` — the ranking in step 4 is a momentum
composite, so a `value_dip` is *meant* to look weak there and must not be alerted on that:
`trend_add` → ALERT when the close is under its 50-day mean (the trend was the thesis);
`trend_add` → ALERT when the close is more than 3% under its 50-day mean (the trend was the
thesis; `signals.MA_TREND_BUFFER` keeps one close under the line from being a break);
`value_dip` / `pullback_add` → ALERT only when the close is at or below the
`invalidation_price` the user recorded, never on MA distance. Cards name the setup_type,
the level breached, the entry price with the move from it, and the session the price came
Expand Down
20 changes: 11 additions & 9 deletions kuroshio/core/allocator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from decimal import Decimal

from kuroshio.core.allocator.signals import BOOK_VOL_WINDOW, MA_TREND
from kuroshio.core.allocator.signals import BOOK_VOL_WINDOW, MA_TREND, MA_TREND_BUFFER
from kuroshio.types import Candidate, Holding, ProposalCard

# setup_types that carry a monitoring rule. "other" (and a missing setup_type) carry
Expand Down Expand Up @@ -85,12 +85,12 @@
"entry_missing": "entry price not recorded",
"no_ma50": "no MA50 for its trend_add — fewer than {ma_trend} traded sessions",
"trend_intact": (
"its trend is intact — {at}, at or above its 50-day moving "
"average of {ma:.2f}"
"its trend is intact — {at}, not more than {buffer:.0%} under its 50-day "
"moving average of {ma:.2f}"
),
"trend_broken": (
"{ticker} was opened as a trend_add and the trend has broken: {at}, "
"below its 50-day moving average of {ma:.2f} ({entry}). "
"more than {buffer:.0%} under its 50-day moving average of {ma:.2f} ({entry}). "
"The setup that justified the position no longer holds."
),
"trail_known": "{stop:.2f} its stop has ratcheted up to (see the ALERT above)",
Expand Down Expand Up @@ -251,10 +251,10 @@
"entry_recorded": "進場價 {entry_price:.2f},目前距進場 {chg:+.1%}",
"entry_missing": "沒有記錄進場價",
"no_ma50": "這檔趨勢加碼沒有 MA50 — 交易日數不到 {ma_trend} 天",
"trend_intact": "趨勢仍然成立 — {at},現價在 50 日均線 {ma:.2f} 之上或持平",
"trend_intact": "趨勢仍然成立 — {at},現價沒有低於 50 日均線 {ma:.2f} 超過 {buffer:.0%}",
"trend_broken": (
"{ticker} 當初以趨勢加碼進場,趨勢已經走壞:{at},低於 50 日均線 "
"{ma:.2f}({entry})。當初進場的理由已經不成立。"
"{ma:.2f} 超過 {buffer:.0%}({entry})。當初進場的理由已經不成立。"
),
"trail_known": "{stop:.2f}(這次檢查剛把停損上調到這裡,見上方警示)",
"trail_earlier": "{stop:.2f}(之前的檢查已經把停損上調到這裡)",
Expand Down Expand Up @@ -751,10 +751,12 @@ def cap_for(theme: str) -> tuple[float, str]:
if ma is None:
thesis_gap[h.ticker] = T["no_ma50"].format(ma_trend=MA_TREND)
continue
if price >= ma:
thesis_note[h.ticker] = T["trend_intact"].format(at=at, ma=ma)
if price >= ma * (1 - MA_TREND_BUFFER):
thesis_note[h.ticker] = T["trend_intact"].format(at=at, ma=ma, buffer=MA_TREND_BUFFER)
continue
reason = T["trend_broken"].format(ticker=h.ticker, at=at, ma=ma, entry=entry)
reason = T["trend_broken"].format(
ticker=h.ticker, at=at, ma=ma, buffer=MA_TREND_BUFFER, entry=entry,
)
details = {"ma50": ma}
elif h.setup_type == "trend_add":
reason = T["trail_breached"].format(ticker=h.ticker, at=at, level=level, entry=entry)
Expand Down
5 changes: 5 additions & 0 deletions kuroshio/core/allocator/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
from kuroshio.types import Holding, Panel

MA_TREND = 50 # the trend_add break window; see engine.propose step 3
# A trend_add breaks only on a close more than this far under its MA50, not on any close under
# it. On 181 sampled US names 2014-2026, one close under the line exited 9.1x a year with 59%
# back above within 5 sessions; 3% under cut that to 4.0x and 25%, median CAGR 3.7% -> 5.3%,
# same median drawdown. Deeper keeps trading exits for a looser rule (5%: 2.7x, 14%).
MA_TREND_BUFFER = 0.03
BOOK_VOL_WINDOW = 20 # trailing-session window book_vol() and engine.propose step 2b share
ATR_WINDOW = 14 # the ATR the stop ratchet trails by; see engine.propose step 3

Expand Down
14 changes: 13 additions & 1 deletion tests/test_allocator.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,18 @@ def test_trend_add_alerts_on_ma_break_and_the_dip_setups_do_not():
assert set(thesis_alerts(cards)) == {"TREND"}


def test_trend_add_breaks_only_past_the_ma50_buffer():
"""One close a little under the MA50 is noise, not a break: 97.00 against 100.00 holds,
96.99 is more than MA_TREND_BUFFER under and EXITs."""
def exited(price):
cards = propose(thesis_portfolio(), [], make_ips(), "us",
prices={"TREND": price}, ma50={"TREND": 100.0})
return "TREND" in thesis_alerts(cards)
assert not exited(99.0)
assert not exited(97.0)
assert exited(96.99)


def test_value_dip_alerts_on_invalidation_breach():
"""Acceptance clause 3: the value_dip's own invalidation price is what ends it —
and the pullback_add answers to the same rule."""
Expand Down Expand Up @@ -489,7 +501,7 @@ def test_swap_selling_an_intact_trend_add_says_the_trend_is_intact():
swap = next(c for c in cards if c.action == "SWAP")
assert swap.sell == "TREND"
assert "its trend is intact" in swap.reason
assert "at or above its 50-day moving average of 100.00" in swap.reason
assert "not more than 3% under its 50-day moving average of 100.00" in swap.reason
assert "broken" not in swap.reason


Expand Down
Loading