Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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: 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
75 changes: 75 additions & 0 deletions src/tradekit/reporting/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────


Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -245,6 +312,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] = []
Expand Down
Loading
Loading