From e905f6cde4e0b41df772b1bd66e31b4993a09aa6 Mon Sep 17 00:00:00 2001 From: David Duncan Date: Sat, 29 Aug 2026 15:32:33 +0000 Subject: [PATCH 1/3] feat(reporting): add Discipline Workshop plan renderer Adds a `render_dw_plan()` surface that emits the morning game plan in the format the MyInvestingClub Discipline Workshop Tab Group Guidelines require, plus the schema fields that format depends on. The existing `render_game_plan()` table (Z-score, covariance, R) is left untouched and remains the analyst-facing view. The two are separate renderers rather than one flagged function because they serve different readers and share almost no layout. Schema: - `MarketCycle` / `RiskLevel` enums (workshop's own vocabulary) - `TradePlan.entry_lines`, `.float_shares`, `.sector`, `.price`, `.volume`, `.risk_level` - `TradePlan.mic_entry_lines()` resolves the three-line entry ladder: explicit `entry_lines`, else a full support/inflexion/resistance triplet (descending for SHORT), else the single `entry`, else nothing. It never invents levels to pad a ladder. - `GamePlanRecord.market_cycle`, `.bias`, `.top_runners` Risk: - `RiskConfig.max_trades`. A round-trip cap is not expressible in R -- a session can stay inside its R budget while churning dozens of trades -- so the overtrading limit needs its own field. All additions are optional with defaults, so SCHEMA_VERSION stays at 1.0 and existing archived records deserialize unchanged. Plans missing an entry ladder or a stop are withheld from the posted body and listed explicitly, so an incomplete setup is visible rather than silently dropped. Tests: 26 new cases covering line format, ladder resolution order, float abbreviation, withholding, and a persistence round-trip. --- src/tradekit/reporting/__init__.py | 10 ++ src/tradekit/reporting/render.py | 139 ++++++++++++++++++- src/tradekit/reporting/runits.py | 6 + src/tradekit/reporting/schema.py | 75 ++++++++++ tests/test_reporting.py | 213 +++++++++++++++++++++++++++++ 5 files changed, 442 insertions(+), 1 deletion(-) diff --git a/src/tradekit/reporting/__init__.py b/src/tradekit/reporting/__init__.py index 781d974..d988d36 100644 --- a/src/tradekit/reporting/__init__.py +++ b/src/tradekit/reporting/__init__.py @@ -50,7 +50,9 @@ trade_from_dict, ) from tradekit.reporting.render import ( + DW_CLOSING_LINE, render_daily_card, + render_dw_plan, render_game_plan, render_multi_day_trend, render_weekly, @@ -58,6 +60,7 @@ from tradekit.reporting.runits import ( RiskConfig, fmt_r, + fmt_r_level, position_risk, r_multiple, target_for_r, @@ -73,6 +76,8 @@ GamePlanRecord, MacroContext, MacroSignal, + MarketCycle, + RiskLevel, TradePlan, TradeRecord, ) @@ -104,6 +109,7 @@ # runits "RiskConfig", "fmt_r", + "fmt_r_level", "r_multiple", "position_risk", "target_for_r", @@ -111,6 +117,8 @@ "Direction", "CovarianceStatus", "AccountKind", + "MarketCycle", + "RiskLevel", "MacroSignal", "MacroContext", "TradePlan", @@ -134,4 +142,6 @@ "render_weekly", "render_multi_day_trend", "render_game_plan", + "render_dw_plan", + "DW_CLOSING_LINE", ] diff --git a/src/tradekit/reporting/render.py b/src/tradekit/reporting/render.py index 6a9d4ea..85c8a05 100644 --- a/src/tradekit/reporting/render.py +++ b/src/tradekit/reporting/render.py @@ -9,15 +9,51 @@ from __future__ import annotations from tradekit.reporting.aggregate import DayRow, WeeklyRollup -from tradekit.reporting.runits import RiskConfig, fmt_r +from tradekit.reporting.runits import RiskConfig, fmt_r, fmt_r_level from tradekit.reporting.schema import ( AccountKind, AccountPnL, DailyReportCard, + Direction, GamePlanRecord, + TradePlan, TradeRecord, ) +# The Discipline Workshop guidelines close every posted plan with this sentence. +# It is boilerplate *by design* -- it states that money flow governs adds, and +# that plan changes need a technical reason. Reproduced verbatim. +DW_CLOSING_LINE = ( + "The money flow will be vital to adding to winners and avoiding watchlist " + "lines if attention is changed to watchlist stock. Technical reasons for " + "adjusting plans are valid." +) + +# Guidelines' recommended simple strategy per direction. +_BIAS_STRATEGY = { + Direction.LONG: "first bounce", + Direction.SHORT: "death candle", +} + + +def _fmt_shares(n: float | None) -> str: + """Format a share count the way the plan format does: ``8.44B``, ``5.12M``. + + Trailing zeros are stripped so a round 1,000,000 float renders ``1M`` rather + than ``1.00M``, matching the guidelines' own examples. + """ + if n is None: + return "—" + for divisor, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")): + if abs(n) >= divisor: + return f"{n / divisor:.2f}".rstrip("0").rstrip(".") + suffix + return f"{n:.0f}" + + +def _fmt_level(x: float | None) -> str: + """Two-decimal price, the unambiguous form for a posted entry/stop line.""" + return "—" if x is None else f"{x:.2f}" + def _money(x: float | None) -> str: if x is None: @@ -184,6 +220,107 @@ def render_weekly(rollup: WeeklyRollup, rows: list[DayRow], config: RiskConfig | return "\n".join(lines).rstrip() + "\n" +def _dw_plan_line(tp: TradePlan) -> str | None: + """One MIC-format plan line, or ``None`` if the plan is not postable. + + A line needs at least one entry level *and* a stop. Without a stop there is + no risk management to review, which is the whole point of the exercise, so + the name is withheld rather than posted half-formed. + """ + levels = tp.mic_entry_lines() + if not levels or tp.stop is None: + return None + ladder = " / ".join(_fmt_level(v) for v in levels) + line = f"{tp.ticker}- {ladder}, stop out {_fmt_level(tp.stop)}" + line += f" Float: {_fmt_shares(tp.float_shares)}" + note = tp.notes or tp.intel_note + if note: + line += f" Notes: {note}" + return line + + +def render_dw_plan(plan: GamePlanRecord, config: RiskConfig | None = None) -> str: + """Render the game plan in the MyInvestingClub Discipline Workshop format. + + This is the shape the workshop's Tab Group Guidelines mandate for a plan + posted to the channel by 9:00 AM market time:: + + Top runners: + MICD-High volume Float: 1M + + ABCD- 2.50 / 2.75 / 3.00, stop out 3.05 Float: 5M Notes: ... + + It deliberately differs from :func:`render_game_plan`, which emits an + analyst-facing table (Z-score, covariance, R). That table is useful + internally but is *not* the format the workshop reads, so the two renderers + are kept separate rather than one being bent into the other. + + Names lacking an entry ladder or a stop are withheld from the plan body and + listed separately, so an incomplete setup is visible instead of silently + dropped or padded with invented levels. + """ + lines: list[str] = [f"## Discipline Workshop Plan — {plan.date}", ""] + + if plan.market_cycle is not None: + lines.append(f"**Market Assessment:** {plan.market_cycle.value} ") + elif plan.market_regime: + lines.append(f"**Market Assessment:** {plan.market_regime} ") + if plan.bias is not None: + strategy = _BIAS_STRATEGY.get(plan.bias, "") + suffix = f" ({strategy})" if strategy else "" + lines.append(f"**Bias:** {plan.bias.value}{suffix} ") + if plan.thesis_ticker: + lines.append(f"**Thesis Trade:** {plan.thesis_ticker} ") + lines.append("") + + # Top runners: the observation list. Falls back to planned names carrying a + # float, since those are the ones with a volume story worth stating. + runners = plan.top_runners or [ + tp for tp in (*plan.fresh_news, *plan.second_day) if tp.float_shares is not None + ] + if runners: + lines.append("Top runners:") + lines.extend(f"{tp.ticker}-High volume Float: {_fmt_shares(tp.float_shares)}" for tp in runners) + lines.append("") + + withheld: list[str] = [] + for tp in (*plan.fresh_news, *plan.second_day): + line = _dw_plan_line(tp) + if line is None: + withheld.append(tp.ticker) + else: + lines.extend([line, ""]) + + lines.extend([DW_CLOSING_LINE, ""]) + + if config is not None: + lines.append("**Risk:** " + _dw_risk_summary(config)) + lines.append("") + + if plan.rules: + lines.append("### Rules for Today") + lines.extend(f"{i + 1}. {r}" for i, r in enumerate(plan.rules)) + lines.append("") + + if withheld: + lines.append(f"> Withheld (no entry ladder or no stop): {', '.join(withheld)}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def _dw_risk_summary(config: RiskConfig) -> str: + """The pre-open risk numbers as one line: per-trade, daily cap, trade cap.""" + parts = [ + f"1R = ${config.r_dollars:,.0f}", + f"per-trade max {fmt_r_level(config.per_trade_max_r, config)}", + f"daily stop {fmt_r_level(config.daily_max_r, config)}", + ] + if config.max_trades is not None: + parts.append(f"max {config.max_trades} trades") + return " | ".join(parts) + + def render_game_plan(plan: GamePlanRecord, config: RiskConfig | None = None) -> str: """Render the morning game plan summary block.""" lines = [ diff --git a/src/tradekit/reporting/runits.py b/src/tradekit/reporting/runits.py index cd31f02..3061934 100644 --- a/src/tradekit/reporting/runits.py +++ b/src/tradekit/reporting/runits.py @@ -29,12 +29,18 @@ class RiskConfig: r_dollars: Dollar value of 1R (the standard per-trade risk budget). daily_max_r: Daily loss limit expressed in R (stored positive). per_trade_max_r: Max risk allowed on a single trade, in R. + max_trades: Hard cap on round-trips for the session, or ``None`` for no + cap. A trade *count* limit is not expressible in R -- a day can sit + inside its R budget while still churning dozens of round-trips, which + is the overtrading failure mode the Discipline Workshop grades on. The + cap therefore has to be its own number. account: Optional account label this config applies to. """ r_dollars: float = 280.0 daily_max_r: float = 3.0 per_trade_max_r: float = 1.0 + max_trades: int | None = None account: str = "" def dollars(self, r_multiple: float) -> float: diff --git a/src/tradekit/reporting/schema.py b/src/tradekit/reporting/schema.py index 5eb2a89..fb5796a 100644 --- a/src/tradekit/reporting/schema.py +++ b/src/tradekit/reporting/schema.py @@ -54,6 +54,37 @@ class AccountKind(str, Enum): SIM = "SIM" +class MarketCycle(str, Enum): + """The market-cycle call, using the Discipline Workshop's fixed vocabulary. + + Identifying the cycle is the guidelines' first and most critical selection + step -- it decides whether the day is aggressive, defensive, or a sit-out. + + * ``HOT`` -- multiple premarket runners holding into the open with volume + and money flow. + * ``IDEAL_SHORT`` -- runners failing with clear confirmation, bouncing + moderately into levels formed premarket. + * ``SLOW`` -- one failed runner, no bounces, attention rotating to + sub-dollar moves. + """ + + HOT = "HOT MARKET" + IDEAL_SHORT = "IDEAL FOR SHORT" + SLOW = "SLOW MARKET" + + +class RiskLevel(str, Enum): + """Per-name risk tier shown in the Discipline Workshop watchlist table. + + Driven primarily by float: an ultra-low-float name is HIGH regardless of how + clean the chart looks, because manipulation risk dominates the setup. + """ + + LOW = "LOW" + MODERATE = "MODERATE" + HIGH = "HIGH" + + # ── Shared building blocks ─────────────────────────────────────────────────── @@ -102,11 +133,47 @@ class TradePlan(BaseModel): intel_note: str = "" notes: str = "" + # -- Discipline Workshop fields ----------------------------------------- + # The MIC plan format is "TICKER- L1 / L2 / L3, stop out X Float: Y Notes: ...", + # which needs three *ordered* entry lines plus a float, and (for the expanded + # watchlist table) price / sector / volume / risk tier. A single ``entry`` + # cannot express the three-line ladder, hence ``entry_lines``. + entry_lines: list[float] = Field(default_factory=list) + price: float | None = None + sector: str = "" + volume: float | None = None + float_shares: float | None = None + risk_level: RiskLevel | None = None + @field_validator("ticker") @classmethod def _upper(cls, v: str) -> str: return v.strip().upper() + def mic_entry_lines(self) -> list[float]: + """The ordered entry lines for the MIC plan format. + + Preference order, first non-empty wins: + + 1. ``entry_lines`` -- set explicitly. + 2. ``support`` / ``inflexion`` / ``resistance`` -- the level triplet the + screener already produces. + 3. ``entry`` alone -- a one-line plan, rendered as-is rather than padded + out with invented levels. + + Ordering follows direction: a LONG ladder ascends into strength, a SHORT + ladder descends into weakness. Returns ``[]`` when nothing is known so + callers can refuse to render the line instead of posting a partial plan. + """ + if self.entry_lines: + return list(self.entry_lines) + triplet = [v for v in (self.support, self.inflexion, self.resistance) if v is not None] + if len(triplet) >= 2: + return sorted(triplet, reverse=self.direction is Direction.SHORT) + if self.entry is not None: + return [self.entry] + return [] + class TradeRecord(BaseModel): """A single executed round-trip, graded against the canonical ladder.""" @@ -247,6 +314,14 @@ class GamePlanRecord(ReportDocument): second_day: list[TradePlan] = Field(default_factory=list) rules: list[str] = Field(default_factory=list) + # -- Discipline Workshop fields ----------------------------------------- + # The MIC guidelines require a market-cycle call from a fixed vocabulary, a + # single declared direction for the day, and a separate "top runners" + # observation list (names with volume, whether or not they are being planned). + market_cycle: MarketCycle | None = None + bias: Direction | None = None + top_runners: list[TradePlan] = Field(default_factory=list) + def all_tickers(self) -> list[str]: """Deduplicated plan tickers, thesis first — DAS Market Viewer order.""" ordered: list[str] = [] diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 5c87b29..5616bb1 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -7,6 +7,7 @@ from tradekit.reporting import ( DISCIPLINE_MAX, + DW_CLOSING_LINE, AccountKind, AccountPnL, DailyReportCard, @@ -15,7 +16,9 @@ FileReportStore, GamePlanRecord, Grade, + MarketCycle, RiskConfig, + RiskLevel, TradePlan, TradeRecord, account_pnl_from_falcon, @@ -31,6 +34,8 @@ position_risk, r_multiple, render_daily_card, + render_dw_plan, + render_game_plan, render_multi_day_trend, render_weekly, target_for_r, @@ -464,3 +469,211 @@ def test_unmapped_account_renders_as_unmapped(self, monkeypatch): out = render_daily_card(card) assert "UNMAPPED (ZZZ)" in out assert "LIVE (ZZZ)" not in out + +# ── Discipline Workshop renderer ───────────────────────────────────────────── + + +def _dw_plan(**overrides) -> GamePlanRecord: + """A game plan shaped like a real Discipline Workshop submission.""" + defaults = dict( + date="2026-08-31", + market_cycle=MarketCycle.HOT, + bias=Direction.LONG, + thesis_ticker="AEO", + fresh_news=[ + TradePlan( + ticker="aeo", + direction=Direction.LONG, + entry_lines=[17.50, 18.00, 18.50], + stop=17.00, + float_shares=3_260_000_000, + price=17.76, + sector="Retail", + volume=4_000_000, + risk_level=RiskLevel.LOW, + notes="Earnings beat, 30% gap, holding above premarket VWAP.", + ), + TradePlan( + ticker="BBLG", + direction=Direction.LONG, + entry_lines=[2.60, 2.80, 3.00], + stop=2.40, + float_shares=5_120_000, + risk_level=RiskLevel.HIGH, + notes="Ultra-low float, 35M volume. Scalp only.", + ), + ], + second_day=[], + rules=["Max 5 trades", "No chasing extensions"], + ) + defaults.update(overrides) + return GamePlanRecord(**defaults) + + +class TestMicEntryLines: + def test_explicit_entry_lines_win(self): + tp = TradePlan(ticker="AEO", entry_lines=[1.0, 2.0, 3.0], support=9.0, entry=8.0) + assert tp.mic_entry_lines() == [1.0, 2.0, 3.0] + + def test_derived_from_level_triplet_ascending_for_long(self): + tp = TradePlan( + ticker="AEO", + direction=Direction.LONG, + support=17.5, + inflexion=18.0, + resistance=18.5, + ) + assert tp.mic_entry_lines() == [17.5, 18.0, 18.5] + + def test_derived_triplet_descends_for_short(self): + tp = TradePlan( + ticker="XYZ", + direction=Direction.SHORT, + support=17.5, + inflexion=18.0, + resistance=18.5, + ) + assert tp.mic_entry_lines() == [18.5, 18.0, 17.5] + + def test_single_entry_not_padded_with_invented_levels(self): + tp = TradePlan(ticker="AEO", entry=17.5) + assert tp.mic_entry_lines() == [17.5] + + def test_no_levels_returns_empty(self): + assert TradePlan(ticker="AEO").mic_entry_lines() == [] + + def test_lone_support_is_not_a_ladder(self): + # One level out of the triplet is not enough to imply a three-line plan. + tp = TradePlan(ticker="AEO", support=17.5) + assert tp.mic_entry_lines() == [] + + +class TestRenderDwPlan: + def test_plan_line_matches_mic_format(self): + out = render_dw_plan(_dw_plan()) + assert "AEO- 17.50 / 18.00 / 18.50, stop out 17.00 Float: 3.26B Notes: " in out + assert "BBLG- 2.60 / 2.80 / 3.00, stop out 2.40 Float: 5.12M Notes: " in out + + def test_top_runners_block(self): + out = render_dw_plan(_dw_plan()) + assert "Top runners:" in out + assert "AEO-High volume Float: 3.26B" in out + + def test_explicit_top_runners_override_derived(self): + plan = _dw_plan(top_runners=[TradePlan(ticker="CIFR", float_shares=2_050_000_000)]) + out = render_dw_plan(plan) + assert "CIFR-High volume Float: 2.05B" in out + assert "AEO-High volume Float" not in out + + def test_market_assessment_and_bias(self): + out = render_dw_plan(_dw_plan()) + assert "**Market Assessment:** HOT MARKET" in out + assert "**Bias:** LONG (first bounce)" in out + + def test_short_bias_names_death_candle_strategy(self): + out = render_dw_plan(_dw_plan(bias=Direction.SHORT)) + assert "**Bias:** SHORT (death candle)" in out + + def test_market_regime_used_when_cycle_unset(self): + out = render_dw_plan(_dw_plan(market_cycle=None, market_regime="Choppy")) + assert "**Market Assessment:** Choppy" in out + + def test_closing_line_present_verbatim(self): + assert DW_CLOSING_LINE in render_dw_plan(_dw_plan()) + + def test_plan_without_stop_is_withheld_not_posted(self): + plan = _dw_plan( + fresh_news=[TradePlan(ticker="NVDA", entry_lines=[10.0, 11.0], stop=None)], + ) + out = render_dw_plan(plan) + assert "NVDA-" not in out + assert "Withheld (no entry ladder or no stop): NVDA" in out + + def test_plan_without_levels_is_withheld(self): + plan = _dw_plan(fresh_news=[TradePlan(ticker="NVDA", stop=9.0)]) + out = render_dw_plan(plan) + assert "Withheld (no entry ladder or no stop): NVDA" in out + + def test_missing_float_renders_placeholder_not_zero(self): + plan = _dw_plan( + fresh_news=[TradePlan(ticker="NVDA", entry_lines=[10.0], stop=9.0)], + ) + out = render_dw_plan(plan) + assert "NVDA- 10.00, stop out 9.00 Float: —" in out + + def test_intel_note_used_when_notes_empty(self): + plan = _dw_plan( + fresh_news=[ + TradePlan(ticker="NVDA", entry_lines=[10.0], stop=9.0, intel_note="Gap and go") + ], + ) + assert "Notes: Gap and go" in render_dw_plan(plan) + + def test_rules_rendered(self): + out = render_dw_plan(_dw_plan()) + assert "1. Max 5 trades" in out + + def test_risk_block_includes_trade_cap(self): + cfg = RiskConfig(r_dollars=280, daily_max_r=3, per_trade_max_r=1, max_trades=5) + out = render_dw_plan(_dw_plan(), cfg) + assert "1R = $280" in out + assert "daily stop 3R ($840)" in out + assert "max 5 trades" in out + + def test_trade_cap_omitted_when_unset(self): + risk_line = render_dw_plan(_dw_plan(), RiskConfig(r_dollars=280)).split("**Risk:**")[1] + assert "trades" not in risk_line.split("\n")[0] + + def test_no_risk_block_without_config(self): + assert "**Risk:**" not in render_dw_plan(_dw_plan()) + + def test_ticker_normalized_to_upper(self): + # 'aeo' was passed lowercase in the fixture. + assert "aeo-" not in render_dw_plan(_dw_plan()) + + def test_table_renderer_unchanged(self): + # The analyst table and the DW format are separate surfaces. + out = render_game_plan(_dw_plan()) + assert "## Morning Game Plan — 2026-08-31" in out + assert "| Ticker | Bias | Setup |" in out + assert DW_CLOSING_LINE not in out + + +class TestShareFormatting: + @pytest.mark.parametrize( + ("n", "expected"), + [ + (8_440_000_000, "8.44B"), + (40_150_000, "40.15M"), + (5_120_000, "5.12M"), + (1_000_000, "1M"), + (1_500_000, "1.5M"), + (65_000, "65K"), + (900, "900"), + ], + ) + def test_share_counts(self, n, expected): + from tradekit.reporting.render import _fmt_shares + + assert _fmt_shares(n) == expected + + def test_none_is_placeholder(self): + from tradekit.reporting.render import _fmt_shares + + assert _fmt_shares(None) == "—" + + +class TestGamePlanRoundTrip: + def test_dw_fields_survive_persistence(self, tmp_path): + store = FileReportStore(root=tmp_path) + store.put(_dw_plan()) + item = store.get("GAMEPLAN", "2026-08-31") + assert item is not None + restored = GamePlanRecord.from_item(item) + assert restored.market_cycle is MarketCycle.HOT + assert restored.bias is Direction.LONG + assert restored.fresh_news[0].entry_lines == [17.50, 18.00, 18.50] + assert restored.fresh_news[0].risk_level is RiskLevel.LOW + assert restored.fresh_news[0].float_shares == 3_260_000_000 + # The rendered output is identical after a persistence round-trip. + assert render_dw_plan(restored) == render_dw_plan(_dw_plan()) From 6bc4748dd7c760ebe67979cbfd4715c4f4b6ceb9 Mon Sep 17 00:00:00 2001 From: David Duncan Date: Sat, 29 Aug 2026 15:38:14 +0000 Subject: [PATCH 2/3] feat(cli): add `tradekit cards gameplan` with --format dw Gives the Discipline Workshop renderer a command-line entry point. Before this, `render_dw_plan` (and `render_game_plan` before it) had no caller anywhere in src/ and could not be reached from a terminal, so the morning workflow had no way to invoke it -- despite README.md advertising `tradekit cards --help`. Adds a `cards` group, matching the name the README already documented, with a `gameplan` subcommand: tradekit cards gameplan [DATE] --format {dw,table,json} DATE defaults to today in ET. `dw` is the channel post format, `table` the existing analyst view, `json` the raw stored item. `--out PATH` is provided because shell redirection is not usable here: the top-level group prints a session banner to stdout with ANSI styling, so `> plan.md` produces a file with escape codes before the heading. `--out` writes the rendered text only, and creates parent directories so a scheduled job can target a dated path. Plan text goes through click.echo rather than console.print -- Rich would interpret bracketed text in a plan's notes (e.g. "[30% gap]") as markup and swallow it. Risk options (--r-dollars, --daily-max-r, --per-trade-max-r, --max-trades) build a RiskConfig only when at least one is supplied. Defaulting one in unconditionally would publish a stock 1R figure as if it were the trader's own; absent options mean the risk block is simply omitted. Also types `ReportDocument.from_item` as `Self` instead of `ReportDocument`, so `GamePlanRecord.from_item(...)` is statically known to be a GamePlanRecord. This removes the need for a cast at the call site and drops the repo's mypy error count from 167 to 161. Tests: 17 new CLI cases (format dispatch, missing-record exit code, stdout left clean on failure, --out byte-for-byte match with stdout and free of ANSI, risk-option defaulting). --- README.md | 12 ++ src/tradekit/cli.py | 112 ++++++++++++++++++ src/tradekit/reporting/schema.py | 10 +- tests/test_cli_cards.py | 191 +++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_cards.py diff --git a/README.md b/README.md index f5c1af3..519a119 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ or as the data engine for a [LifeOS][pai] pack (formerly Personal AI Infrastruct rubric across game plan and review, persisted as document-oriented records (NoSQL-native, object-storage-archivable) so results are comparable day over day. See `tradekit cards --help` and `tradekit.reporting`. +- **Discipline Workshop plans** — render the stored game plan in the format the + MyInvestingClub Discipline Workshop expects in-channel by 9:00 AM market time: + `tradekit cards gameplan --format dw`. --- @@ -93,10 +96,19 @@ tradekit levels NVDA # Watchlist scan tradekit watchlist default +# Today's game plan in Discipline Workshop post format +tradekit cards gameplan --format dw + +# A specific day, with session risk limits appended, written to a file +tradekit cards gameplan 2026-08-31 --max-trades 5 --r-dollars 280 --out plan.md + # Interactive setup wizard (populates .env) tradekit init ``` +Prefer `--out` over shell redirection for the plan: `tradekit` prints a session +banner on stdout, so `> plan.md` would capture that too. + Run `tradekit --help` for the full command list. --- diff --git a/src/tradekit/cli.py b/src/tradekit/cli.py index 4f57c44..bdbc792 100644 --- a/src/tradekit/cli.py +++ b/src/tradekit/cli.py @@ -2391,3 +2391,115 @@ def gex(ticker: str, max_dte: int, rate: float, as_json: bool): # click.echo, not console.print — rich would treat the markdown table's # square brackets as markup and eat them. click.echo(format_markdown(result)) + + +@cli.group() +def cards(): + """Canonical report cards: render and inspect stored game plans.""" + + +def _risk_config_from_opts( + r_dollars: float | None, + daily_max_r: float | None, + per_trade_max_r: float | None, + max_trades: int | None, +): + """Build a RiskConfig only if the caller actually specified risk numbers. + + Returning ``None`` when nothing was passed matters: the renderers omit the + risk block entirely for ``None``, whereas a default-constructed config would + silently publish someone else's 1R figure as if it were the trader's own. + """ + from tradekit.reporting import RiskConfig + + if all(v is None for v in (r_dollars, daily_max_r, per_trade_max_r, max_trades)): + return None + defaults = RiskConfig() + return RiskConfig( + r_dollars=defaults.r_dollars if r_dollars is None else r_dollars, + daily_max_r=defaults.daily_max_r if daily_max_r is None else daily_max_r, + per_trade_max_r=defaults.per_trade_max_r if per_trade_max_r is None else per_trade_max_r, + max_trades=max_trades, + ) + + +@cards.command("gameplan") +@click.argument("date", required=False) +@click.option( + "--format", + "fmt", + type=click.Choice(["dw", "table", "json"]), + default="dw", + help="dw = Discipline Workshop post format, table = analyst view, json = raw record.", +) +@click.option( + "--out", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help="Write to a file instead of stdout. Use this when piping — stdout carries a banner.", +) +@click.option("--scope", default="GLOBAL", help="Record scope partition.") +@click.option( + "--store", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Report store root (default ~/.tradekit/reports).", +) +@click.option("--r-dollars", type=float, default=None, help="Dollar value of 1R.") +@click.option("--daily-max-r", type=float, default=None, help="Daily loss limit in R.") +@click.option("--per-trade-max-r", type=float, default=None, help="Max risk per trade in R.") +@click.option("--max-trades", type=int, default=None, help="Hard cap on round-trips for the session.") +def cards_gameplan( + date: str | None, + fmt: str, + out: Path | None, + scope: str, + store: Path | None, + r_dollars: float | None, + daily_max_r: float | None, + per_trade_max_r: float | None, + max_trades: int | None, +): + """Render the stored game plan for DATE (default: today, ET). + + The 'dw' format is the one the Discipline Workshop expects in the channel by + 9:00 AM market time; 'table' is the analyst view with Z-scores and + covariance. + """ + import json as _json + + from tradekit.reporting import ( + FileReportStore, + GamePlanRecord, + render_dw_plan, + render_game_plan, + ) + + date = date or now_et().strftime("%Y-%m-%d") + report_store = FileReportStore(root=store) + + item = report_store.get("GAMEPLAN", date, scope=scope) + if item is None: + # Name the exact location checked, so a wrong --store or --scope is + # obvious rather than looking like a missing plan. Raised as a + # ClickException so it lands on stderr unwrapped and unstyled -- callers + # piping the plan need the error kept out of the document. + raise click.ClickException( + f"No game plan stored for {date} (scope {scope}) under {report_store.root}" + ) + + if fmt == "json": + text = _json.dumps(item, indent=2, sort_keys=True) + else: + plan = GamePlanRecord.from_item(item) + config = _risk_config_from_opts(r_dollars, daily_max_r, per_trade_max_r, max_trades) + text = render_dw_plan(plan, config) if fmt == "dw" else render_game_plan(plan, config) + + if out is not None: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text) + console.print(f"[green]Wrote {fmt} game plan for {date} to {out}[/green]") + else: + # click.echo, not console.print: Rich would interpret bracketed text in + # the plan's notes as markup and eat it. + click.echo(text, nl=False) diff --git a/src/tradekit/reporting/schema.py b/src/tradekit/reporting/schema.py index fb5796a..0d599c5 100644 --- a/src/tradekit/reporting/schema.py +++ b/src/tradekit/reporting/schema.py @@ -22,7 +22,7 @@ import re from enum import Enum -from typing import ClassVar +from typing import ClassVar, Self from pydantic import BaseModel, Field, field_validator @@ -296,7 +296,13 @@ def archive_path(self) -> str: return f"{self.record_type.lower()}/{self.scope()}/{self.date}.json" @classmethod - def from_item(cls, item: dict) -> "ReportDocument": + def from_item(cls, item: dict) -> Self: + """Rebuild a document from its stored item, dropping the index keys. + + Typed as ``Self`` rather than ``ReportDocument`` so that + ``GamePlanRecord.from_item(...)`` is statically known to be a + ``GamePlanRecord``; callers would otherwise have to cast. + """ data = {k: v for k, v in item.items() if k not in ("pk", "sk", "record_type")} return cls.model_validate(data) diff --git a/tests/test_cli_cards.py b/tests/test_cli_cards.py new file mode 100644 index 0000000..36ea2f0 --- /dev/null +++ b/tests/test_cli_cards.py @@ -0,0 +1,191 @@ +"""Tests for the ``tradekit cards`` command group.""" + +import json + +import pytest +from click.testing import CliRunner + +from tradekit.cli import cli +from tradekit.reporting import ( + Direction, + FileReportStore, + GamePlanRecord, + MarketCycle, + RiskLevel, + TradePlan, +) + + +@pytest.fixture +def store_root(tmp_path): + """A report store holding one game plan for 2026-08-31.""" + store = FileReportStore(root=tmp_path) + store.put( + GamePlanRecord( + date="2026-08-31", + market_cycle=MarketCycle.HOT, + bias=Direction.LONG, + thesis_ticker="AEO", + fresh_news=[ + TradePlan( + ticker="AEO", + direction=Direction.LONG, + entry_lines=[17.50, 18.00, 18.50], + stop=17.00, + float_shares=3_260_000_000, + risk_level=RiskLevel.LOW, + notes="Earnings beat, holding premarket VWAP.", + ) + ], + rules=["Max 5 trades"], + ) + ) + return tmp_path + + +def _run(args): + return CliRunner().invoke(cli, args) + + +def _errtext(result) -> str: + """Error text, tolerating Click versions that split or merge the streams.""" + try: + return result.stderr or result.output + except ValueError: # stderr not separately captured + return result.output + + +class TestCardsGameplan: + def test_dw_is_the_default_format(self, store_root): + result = _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root)]) + assert result.exit_code == 0 + assert "AEO- 17.50 / 18.00 / 18.50, stop out 17.00 Float: 3.26B" in result.output + assert "Top runners:" in result.output + + def test_explicit_dw_format(self, store_root): + result = _run( + ["cards", "gameplan", "2026-08-31", "--format", "dw", "--store", str(store_root)] + ) + assert result.exit_code == 0 + assert "**Market Assessment:** HOT MARKET" in result.output + + def test_table_format_is_the_analyst_view(self, store_root): + result = _run( + ["cards", "gameplan", "2026-08-31", "--format", "table", "--store", str(store_root)] + ) + assert result.exit_code == 0 + assert "| Ticker | Bias | Setup |" in result.output + assert "stop out" not in result.output + + def test_json_format_emits_the_raw_item(self, store_root): + result = _run( + ["cards", "gameplan", "2026-08-31", "--format", "json", "--store", str(store_root)] + ) + assert result.exit_code == 0 + payload = json.loads(result.output[result.output.index("{") :]) + assert payload["record_type"] == "GAMEPLAN" + assert payload["fresh_news"][0]["entry_lines"] == [17.5, 18.0, 18.5] + + def test_invalid_format_rejected(self, store_root): + result = _run( + ["cards", "gameplan", "2026-08-31", "--format", "xml", "--store", str(store_root)] + ) + assert result.exit_code != 0 + + def test_missing_plan_exits_nonzero_and_names_the_path(self, store_root): + result = _run(["cards", "gameplan", "2099-01-01", "--store", str(store_root)]) + assert result.exit_code == 1 + assert "No game plan stored for 2099-01-01" in _errtext(result) + + def test_missing_plan_writes_nothing_to_stdout(self, store_root): + # A failure must not leave half a document in a pipe. + result = _run(["cards", "gameplan", "2099-01-01", "--store", str(store_root)]) + assert "Discipline Workshop Plan" not in result.stdout + + def test_unknown_scope_is_reported_not_silently_empty(self, store_root): + result = _run( + ["cards", "gameplan", "2026-08-31", "--scope", "NOPE", "--store", str(store_root)] + ) + assert result.exit_code == 1 + assert "scope NOPE" in _errtext(result) + + +class TestOutFile: + def test_out_writes_clean_text_without_the_banner(self, store_root, tmp_path): + dest = tmp_path / "posts" / "plan.md" + result = _run( + ["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--out", str(dest)] + ) + assert result.exit_code == 0 + text = dest.read_text() + # The file must be postable as-is: no ANSI escapes, no session banner. + assert "\x1b[" not in text + assert "ET —" not in text + assert text.startswith("## Discipline Workshop Plan — 2026-08-31") + + def test_out_creates_parent_directories(self, store_root, tmp_path): + dest = tmp_path / "a" / "b" / "plan.md" + _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--out", str(dest)]) + assert dest.exists() + + def test_out_matches_stdout_rendering(self, store_root, tmp_path): + dest = tmp_path / "plan.md" + _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--out", str(dest)]) + piped = _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root)]) + assert dest.read_text() in piped.output + + +class TestRiskOptions: + def test_no_risk_block_when_no_options_given(self, store_root): + result = _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root)]) + assert "**Risk:**" not in result.output + + def test_max_trades_surfaces_the_cap(self, store_root): + result = _run( + [ + "cards", + "gameplan", + "2026-08-31", + "--store", + str(store_root), + "--max-trades", + "5", + ] + ) + assert "max 5 trades" in result.output + + def test_partial_risk_options_fill_from_defaults(self, store_root): + # Only --r-dollars given; the R-based limits should still render. + result = _run( + ["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--r-dollars", "500"] + ) + assert "1R = $500" in result.output + assert "daily stop 3R ($1,500)" in result.output + + def test_r_dollars_scales_the_daily_stop(self, store_root): + result = _run( + [ + "cards", + "gameplan", + "2026-08-31", + "--store", + str(store_root), + "--r-dollars", + "280", + "--daily-max-r", + "2", + ] + ) + assert "daily stop 2R ($560)" in result.output + + +class TestCardsGroup: + def test_group_help_lists_gameplan(self): + result = _run(["cards", "--help"]) + assert result.exit_code == 0 + assert "gameplan" in result.output + + def test_gameplan_help_documents_dw_format(self): + result = _run(["cards", "gameplan", "--help"]) + assert result.exit_code == 0 + assert "Discipline Workshop" in result.output From b65557b94e8e600670c9f397753e3841b8bf6707 Mon Sep 17 00:00:00 2001 From: David Duncan Date: Tue, 8 Sep 2026 18:40:21 -0500 Subject: [PATCH 3/3] Apply ruff formatting after the rebase onto main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch predates `ruff format --check` passing in CI, so four files needed reformatting: cli.py and test_cli_cards.py where the rebase resolution placed the `cards` group after `gex`, plus render.py and test_reporting.py. No behaviour change — 175 tests pass and both CLI groups resolve. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ex9JtD1UazxwQkFQkggAse --- src/tradekit/cli.py | 4 +--- src/tradekit/reporting/render.py | 4 +--- tests/test_cli_cards.py | 28 +++++++--------------------- tests/test_reporting.py | 5 ++--- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/tradekit/cli.py b/src/tradekit/cli.py index bdbc792..4a2d138 100644 --- a/src/tradekit/cli.py +++ b/src/tradekit/cli.py @@ -2484,9 +2484,7 @@ def cards_gameplan( # obvious rather than looking like a missing plan. Raised as a # ClickException so it lands on stderr unwrapped and unstyled -- callers # piping the plan need the error kept out of the document. - raise click.ClickException( - f"No game plan stored for {date} (scope {scope}) under {report_store.root}" - ) + raise click.ClickException(f"No game plan stored for {date} (scope {scope}) under {report_store.root}") if fmt == "json": text = _json.dumps(item, indent=2, sort_keys=True) diff --git a/src/tradekit/reporting/render.py b/src/tradekit/reporting/render.py index 85c8a05..5419cb2 100644 --- a/src/tradekit/reporting/render.py +++ b/src/tradekit/reporting/render.py @@ -275,9 +275,7 @@ def render_dw_plan(plan: GamePlanRecord, config: RiskConfig | None = None) -> st # Top runners: the observation list. Falls back to planned names carrying a # float, since those are the ones with a volume story worth stating. - runners = plan.top_runners or [ - tp for tp in (*plan.fresh_news, *plan.second_day) if tp.float_shares is not None - ] + runners = plan.top_runners or [tp for tp in (*plan.fresh_news, *plan.second_day) if tp.float_shares is not None] if runners: lines.append("Top runners:") lines.extend(f"{tp.ticker}-High volume Float: {_fmt_shares(tp.float_shares)}" for tp in runners) diff --git a/tests/test_cli_cards.py b/tests/test_cli_cards.py index 36ea2f0..026002c 100644 --- a/tests/test_cli_cards.py +++ b/tests/test_cli_cards.py @@ -63,33 +63,25 @@ def test_dw_is_the_default_format(self, store_root): assert "Top runners:" in result.output def test_explicit_dw_format(self, store_root): - result = _run( - ["cards", "gameplan", "2026-08-31", "--format", "dw", "--store", str(store_root)] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--format", "dw", "--store", str(store_root)]) assert result.exit_code == 0 assert "**Market Assessment:** HOT MARKET" in result.output def test_table_format_is_the_analyst_view(self, store_root): - result = _run( - ["cards", "gameplan", "2026-08-31", "--format", "table", "--store", str(store_root)] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--format", "table", "--store", str(store_root)]) assert result.exit_code == 0 assert "| Ticker | Bias | Setup |" in result.output assert "stop out" not in result.output def test_json_format_emits_the_raw_item(self, store_root): - result = _run( - ["cards", "gameplan", "2026-08-31", "--format", "json", "--store", str(store_root)] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--format", "json", "--store", str(store_root)]) assert result.exit_code == 0 payload = json.loads(result.output[result.output.index("{") :]) assert payload["record_type"] == "GAMEPLAN" assert payload["fresh_news"][0]["entry_lines"] == [17.5, 18.0, 18.5] def test_invalid_format_rejected(self, store_root): - result = _run( - ["cards", "gameplan", "2026-08-31", "--format", "xml", "--store", str(store_root)] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--format", "xml", "--store", str(store_root)]) assert result.exit_code != 0 def test_missing_plan_exits_nonzero_and_names_the_path(self, store_root): @@ -103,9 +95,7 @@ def test_missing_plan_writes_nothing_to_stdout(self, store_root): assert "Discipline Workshop Plan" not in result.stdout def test_unknown_scope_is_reported_not_silently_empty(self, store_root): - result = _run( - ["cards", "gameplan", "2026-08-31", "--scope", "NOPE", "--store", str(store_root)] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--scope", "NOPE", "--store", str(store_root)]) assert result.exit_code == 1 assert "scope NOPE" in _errtext(result) @@ -113,9 +103,7 @@ def test_unknown_scope_is_reported_not_silently_empty(self, store_root): class TestOutFile: def test_out_writes_clean_text_without_the_banner(self, store_root, tmp_path): dest = tmp_path / "posts" / "plan.md" - result = _run( - ["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--out", str(dest)] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--out", str(dest)]) assert result.exit_code == 0 text = dest.read_text() # The file must be postable as-is: no ANSI escapes, no session banner. @@ -156,9 +144,7 @@ def test_max_trades_surfaces_the_cap(self, store_root): def test_partial_risk_options_fill_from_defaults(self, store_root): # Only --r-dollars given; the R-based limits should still render. - result = _run( - ["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--r-dollars", "500"] - ) + result = _run(["cards", "gameplan", "2026-08-31", "--store", str(store_root), "--r-dollars", "500"]) assert "1R = $500" in result.output assert "daily stop 3R ($1,500)" in result.output diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 5616bb1..e826070 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -470,6 +470,7 @@ def test_unmapped_account_renders_as_unmapped(self, monkeypatch): assert "UNMAPPED (ZZZ)" in out assert "LIVE (ZZZ)" not in out + # ── Discipline Workshop renderer ───────────────────────────────────────────── @@ -603,9 +604,7 @@ def test_missing_float_renders_placeholder_not_zero(self): def test_intel_note_used_when_notes_empty(self): plan = _dw_plan( - fresh_news=[ - TradePlan(ticker="NVDA", entry_lines=[10.0], stop=9.0, intel_note="Gap and go") - ], + fresh_news=[TradePlan(ticker="NVDA", entry_lines=[10.0], stop=9.0, intel_note="Gap and go")], ) assert "Notes: Gap and go" in render_dw_plan(plan)