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
10 changes: 9 additions & 1 deletion keel/strategy/rules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ class Signal:
#: (`backtest._closed_trade`, which picks the branch) and the consumer (`Trade.outcome`) cannot
#: drift: an inline `Literal[...]` repeated in both places lets one side gain a value the other
#: silently rejects, and the mismatch only shows up as an `arg-type` error at the constructor.
type TradeOutcome = Literal["win", "loss", "open", "scratch"]
#
#: Spelled as a plain assignment, NOT the PEP 695 `type TradeOutcome = ...` statement, and that
#: is load-bearing rather than stylistic: `get_type_hints()` leaves a `type` alias as a
#: `TypeAliasType`, whose `get_origin()` is `None`, while the assignment form resolves through to
#: `Literal`. `commands.rules._declared_choices` validates an operator's `rules add --params`
#: by testing exactly `get_origin(hint) is Literal`, so the modern spelling would silently turn
#: that validation off for any param annotated with it. `StopMethod`/`TargetMethod` in the rule
#: modules use this same form for the same reason -- keep them consistent.
TradeOutcome = Literal["win", "loss", "open", "scratch"]


@dataclass
Expand Down
4 changes: 2 additions & 2 deletions packages/keel-broker-api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[project]
name = "keel-broker-api"
version = "0.7.0"
version = "0.7.1"
description = "Broker port, domain types, capability model, and conformance suite for keel"
requires-python = ">=3.14.4"
# Pinned `==`: see the note in the root `pyproject.toml`. An unpinned sibling is satisfied by
# whatever is already installed, which is how a deployment ends up running mixed versions.
dependencies = ["keel-core==0.7.0"]
dependencies = ["keel-core==0.7.1"]

[project.optional-dependencies]
conformance = ["pytest>=9.1.1"]
Expand Down
4 changes: 2 additions & 2 deletions packages/keel-broker-coinbase/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[project]
name = "keel-broker-coinbase"
version = "0.7.0"
version = "0.7.1"
description = "Coinbase Advanced Trade adapter for keel"
requires-python = ">=3.14.4"
# Siblings pinned `==` (see the root `pyproject.toml`); the third-party SDK is not, because it
# has its own release cycle and is not cut from this repo.
dependencies = ["keel-core==0.7.0", "keel-broker-api==0.7.0", "coinbase-advanced-py>=1.8.4"]
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1", "coinbase-advanced-py>=1.8.4"]

[project.entry-points."keel.brokers"]
coinbase = "keel_broker_coinbase:CoinbaseAdapter"
Expand Down
4 changes: 2 additions & 2 deletions packages/keel-broker-fake/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[project]
name = "keel-broker-fake"
version = "0.7.0"
version = "0.7.1"
description = "A deliberately divergent second broker adapter, to keep the port honest"
requires-python = ">=3.14.4"
# Pinned `==`: see the note in the root `pyproject.toml`.
dependencies = ["keel-core==0.7.0", "keel-broker-api==0.7.0"]
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1"]

[project.entry-points."keel.brokers"]
fake = "keel_broker_fake:FakeAdapter"
Expand Down
4 changes: 2 additions & 2 deletions packages/keel-broker-robinhood/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[project]
name = "keel-broker-robinhood"
version = "0.7.0"
version = "0.7.1"
description = "Robinhood Crypto Trading API v2 adapter for keel"
requires-python = ">=3.14.4"
# `pynacl` is here and nowhere else in the workspace: Robinhood signs every request with an
# Ed25519 key, which no other venue keel talks to requires. Keeping it a dependency of this
# package alone means an engine that never installs this adapter never installs the crypto stack
# either -- the whole point of adapters being separate distributions.
dependencies = ["keel-core==0.7.0", "keel-broker-api==0.7.0", "pynacl>=1.5.0", "requests>=2.32.0"]
dependencies = ["keel-core==0.7.1", "keel-broker-api==0.7.1", "pynacl>=1.5.0", "requests>=2.32.0"]

[project.entry-points."keel.brokers"]
robinhood = "keel_broker_robinhood:RobinhoodAdapter"
Expand Down
2 changes: 1 addition & 1 deletion packages/keel-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keel-core"
version = "0.7.0"
version = "0.7.1"
description = "Shared domain types, configuration, and logging for keel"
requires-python = ">=3.14.4"
dependencies = ["pyyaml>=6.0.3", "python-dotenv>=1.2.2"]
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "keel-trader"
version = "0.7.0"
version = "0.7.1"
description = "Offline-first, broker-agnostic, rule-based spot-trading agent (halal policy by default)"
readme = "README.md"
authors = [
Expand All @@ -22,11 +22,11 @@ dependencies = [
# in one build, so `==` states a fact rather than guessing at compatibility. The cost is that
# a version bump must move these numbers too -- the bump already edits every package's
# `version`, and `tests/test_packaging.py` fails the build if a pin is left behind.
"keel-core==0.7.0",
"keel-broker-api==0.7.0",
"keel-core==0.7.1",
"keel-broker-api==0.7.1",
# keel/cli.py and keel/data/cb_client.py still import the Coinbase SDK directly; depending
# on the adapter keeps it available transitively. Phase B deletes those imports and this.
"keel-broker-coinbase==0.7.0",
"keel-broker-coinbase==0.7.1",
]

[project.scripts]
Expand Down
38 changes: 38 additions & 0 deletions tests/commands/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,23 @@ def test_available_lines_with_amount_is_ok_style_and_informative() -> None:
assert "USDC" in line.text


def test_available_lines_without_updated_ts_says_unknown_rather_than_now() -> None:
"""Same silent-"now" hazard as the autonomy line, on a FRESHNESS stamp.

`updated_ts` is a separate field from `amount` and can be absent while the amount is
present. A staleness marker that quietly reports the current instant is worse than one that
admits it does not know, because an operator reads it as "just refreshed".
"""
available = AvailableBalance(Decimal("10234.5"), "USDC", None, None)
lines = _available_lines(available)
assert len(lines) == 1
line = lines[0]
assert line.style == "ok"
assert "10,234.50" in line.text
assert "unknown" in line.text
assert _human_dt(NOW_TS) not in line.text


def test_available_lines_with_error_is_warn_style() -> None:
available = AvailableBalance(None, "USDC", NOW_TS, "no USDC balance")
lines = _available_lines(available)
Expand Down Expand Up @@ -491,6 +508,27 @@ def test_autonomy_lapsed_line_uses_human_readable_timestamp() -> None:
assert _human_dt(until) in lapsed_line.text


def test_autonomy_lapsed_line_without_a_deadline_says_unknown_rather_than_now() -> None:
"""A missing `autonomous_until` must not render as "lapsed this instant".

`_human_dt` does not fail on `None` -- `time.localtime(None)` means "now" -- so without the
guard this branch printed the CURRENT time as though it were the recorded lapse. The second
assertion is the load-bearing one: absence has to read as absence, not as a fresh fact.
"""
autonomy = AutonomyStatus(
live=False,
autonomous=True,
autonomous_until=None,
updated_ts=NOW_TS,
profile_readable=True,
)
report = _base_report(autonomy=autonomy)
lines = build_screen(report, NOW_TS)
lapsed_line = next(line for line in lines if "LAPSED" in line.text)
assert "unknown" in lapsed_line.text
assert _human_dt(NOW_TS) not in lapsed_line.text


def test_autonomy_lapses_at_line_uses_human_readable_timestamp() -> None:
until = NOW_TS + 3600
autonomy = AutonomyStatus(
Expand Down
16 changes: 16 additions & 0 deletions tests/strategy/test_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from decimal import Decimal

import pytest

from keel.strategy.rules.base import Trade
from keel.strategy.stats import BacktestResult, summarize
from keel.types import Side
Expand Down Expand Up @@ -134,3 +136,17 @@ def test_summarize_open_trade_included_in_trades_but_excluded_from_aggregates()

assert result.trades == [closed, still_open]
assert result.n_trades == 1


def test_summarize_rejects_a_closed_trade_carrying_no_pnl() -> None:
"""A closed trade with no realised P&L is a broken input, and must SAY so.

Only an open trade may omit `pnl`; every close path sets it. The value of raising here is
the diagnostic: unguarded, this surfaced as `TypeError: unsupported operand type(s) for +:
'Decimal' and 'NoneType'` raised from inside a generator, with no way to tell which trade
caused it. The outcome is asserted in the message for exactly that reason.
"""
with pytest.raises(ValueError, match="pnl=None") as excinfo:
summarize([_trade("win", pnl=None)])

assert "outcome='win'" in str(excinfo.value)
22 changes: 21 additions & 1 deletion tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,28 @@ def options(extra: list[str]):
# `--strict` CLEARS implicit_reexport; the config states that as `no_implicit_reexport`.
expected.add(name if value else f"no_{name}")

# Dropped explicitly, not by accident: `warn_redundant_casts` is part of the bundle but is
# global-only, so it lives in `[tool.mypy]`. It happens not to appear in the diff above
# because it is already mypy's default -- if that default ever flips it WOULD appear, and
# this test would then demand it in a per-module section where mypy refuses to accept it,
# leaving the config unsatisfiable. Excluding it by name keeps that impossible.
expected.discard("warn_redundant_casts")

broker_override = next(
o for o in _mypy_overrides() if o.get(_STRICT_MARKER) and "keel_broker_api.*" in o["module"]
(
o
for o in _mypy_overrides()
if o.get(_STRICT_MARKER) and "keel_broker_api.*" in o["module"]
),
None,
)
# Asserted rather than left to `next()`: re-collapsing the block to `strict = true` is THE
# regression this test exists to catch, and a bare `StopIteration` from an exhausted
# generator is the least legible way pytest can report it.
assert broker_override is not None, (
f"no broker override sets {_STRICT_MARKER!r} -- if the block was collapsed back to "
"`strict = true`, that re-enables strict mode globally for every module (see the "
"comment above the block in pyproject.toml); expand it into its flags again"
)
configured = {k for k, v in broker_override.items() if k != "module" and v is True}

Expand Down
12 changes: 6 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading