Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down Expand Up @@ -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.

---
Expand Down
112 changes: 112 additions & 0 deletions src/tradekit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2349,3 +2349,115 @@ def debate(ticker: str, period: str, level: str, no_persist: bool, source: str |

if not no_persist:
console.print(f"\n[dim]Transcript saved to {debate_dir()}[/dim]")


@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)
10 changes: 10 additions & 0 deletions src/tradekit/reporting/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@
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,
)
from tradekit.reporting.runits import (
RiskConfig,
fmt_r,
fmt_r_level,
position_risk,
r_multiple,
target_for_r,
Expand All @@ -71,6 +74,8 @@
GamePlanRecord,
MacroContext,
MacroSignal,
MarketCycle,
RiskLevel,
TradePlan,
TradeRecord,
)
Expand Down Expand Up @@ -100,13 +105,16 @@
# runits
"RiskConfig",
"fmt_r",
"fmt_r_level",
"r_multiple",
"position_risk",
"target_for_r",
# schema
"Direction",
"CovarianceStatus",
"AccountKind",
"MarketCycle",
"RiskLevel",
"MacroSignal",
"MacroContext",
"TradePlan",
Expand All @@ -130,4 +138,6 @@
"render_weekly",
"render_multi_day_trend",
"render_game_plan",
"render_dw_plan",
"DW_CLOSING_LINE",
]
139 changes: 138 additions & 1 deletion src/tradekit/reporting/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -173,6 +209,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 = [
Expand Down
6 changes: 6 additions & 0 deletions src/tradekit/reporting/runits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading