
Trader Memory Core
- 828 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
trader-memory-core is a persistent trading journal skill that records investment theses from screener output through position sizing, review checkpoints, and closed-trade postmortems with P&L and MAE/MFE analysis.
About
trader-memory-core is a claude-trading-skills state layer that bundles screening, analysis, position sizing, and portfolio outputs into one thesis object per investment idea. Developers and quantitative traders invoke it on phrases like register thesis, track this idea, thesis status, review due, close position, postmortem, or trading journal. The skill registers theses from screener outputs, manages state transitions, attaches sizing metadata, enforces review due dates, and generates postmortem reports with profit-and-loss plus MAE/MFE analysis when positions close. Trader Memory Core answers what was believed, what changed, and what happened across the full idea lifecycle. Reach for it when AI agents must maintain structured trading memory instead of losing context between screens, entries, and exits.
- Bundles screening, analysis, sizing and portfolio outputs into one thesis object
- Tracks 5 thesis types: dividend_income, growth_momentum, mean_reversion, earnings_drift, pivot_breakout
- Manages full lifecycle state transitions: IDEA → ENTRY_READY → ACTIVE → CLOSED
- Generates postmortems with P&L, MAE/MFE and lessons learned
- Triggered by natural phrases such as "register thesis", "track this idea", "thesis status", "close position" or "postmor
Trader Memory Core by the numbers
- 828 all-time installs (skills.sh)
- +95 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #568 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill trader-memory-coreAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 828 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you track investment theses to postmortem?
Maintain a persistent record of every investment thesis from initial screen to final postmortem.
Who is it for?
Developers building AI-assisted trading workflows who need persistent thesis state from screen through closed position review.
Skip if: Teams needing market data APIs, order execution, or backtesting engines without a structured thesis journal layer.
When should I use this skill?
User says register thesis, track this idea, thesis status, review due, close position, postmortem, or trading journal.
What you get
Structured thesis objects, review schedules, position sizing notes, and closed-trade postmortem reports with P&L and MAE/MFE.
- Thesis objects
- Review schedules
- Postmortem reports
By the numbers
- Postmortem reports include P&L plus MAE and MFE analysis fields
Files
Trader Memory Core
Overview
Persistent state layer that bundles screening → analysis → position sizing → portfolio management outputs into a single "thesis object" per investment idea. Tracks what you thought, what happened, and what you learned — across conversations.
Phase 1 supports single-ticker theses: dividend_income, growth_momentum, mean_reversion, earnings_drift, pivot_breakout.
When to Use
- After a screener (kanchi, earnings-trade-analyzer, vcp, pead, canslim, edge-candidate-agent) produces candidates
- When transitioning a thesis from IDEA → ENTRY_READY → ACTIVE → CLOSED
- When attaching position-sizer output to a thesis
- When checking which theses are due for review
- When closing a position and generating a postmortem with lessons learned
Prerequisites
- Python 3.10+
pyyaml(already in project dependencies)jsonschema(already inpyproject.toml; required bythesis_store.pyand every command that imports it, includingthesis_ingest.pyandthesis_review.py)- FMP API key (optional, only for MAE/MFE calculation in postmortem)
How to invoke the CLI
Use the stdlib-only launcher trader_memory_cli.py for all CLI work. It transparently routes through uv run --project <repo> when uv is available, so the repo's pinned jsonschema is reachable even from a foreign cwd or from python3 with no global jsonschema (e.g. cron / Hermes profile runs):
# From inside the repo
python3 skills/trader-memory-core/scripts/trader_memory_cli.py store --state-dir state/theses list
# From any other cwd (cron, profile, distribution runner) — point the launcher at the repo
export CLAUDE_TRADING_SKILLS_REPO=/path/to/claude-trading-skills
python3 "$CLAUDE_TRADING_SKILLS_REPO/skills/trader-memory-core/scripts/trader_memory_cli.py" \
store --state-dir /path/to/state/theses listSubcommands: store → thesis_store.py, ingest → thesis_ingest.py, review → thesis_review.py. Everything after the subcommand is forwarded verbatim, so existing argument flags (--state-dir, transition, open-position, etc.) work unchanged.
If the launcher reports that jsonschema is not importable AND uv is not on PATH, the actionable fixes (in priority order) are:
1. Install uv (https://docs.astral.sh/uv/) and re-run the launcher. 2. Install the project's dependencies into the current interpreter:
uv pip install -e /path/to/claude-trading-skills
# or, as a last resort:
python3 -m pip install jsonschemaDo not treat the thesis store as unavailable and do not mutate state/theses/*.yaml by hand to work around a missing dependency — schema validation is part of thesis state integrity.
Workflow
1. Register — Ingest screener output as thesis
Read the screener's JSON output and convert to thesis using the appropriate adapter.
python3 skills/trader-memory-core/scripts/trader_memory_cli.py ingest \
--source kanchi-dividend-sop \
--input reports/kanchi_entry_signals_2026-03-14.json \
--state-dir state/theses/Supported sources: kanchi-dividend-sop, earnings-trade-analyzer, vcp-screener, pead-screener, canslim-screener, edge-candidate-agent, manual.
Each thesis starts in IDEA status.
Manual brokerage entry (fractional shares)
For trades that did not come from a screener — e.g. fractional-share brokers (IBKR, Robinhood, IBI Smart, Alpaca, eToro) or hand journaling — use the manual source with a free-form JSON file (a single object or an array):
{
"ticker": "AMD",
"thesis_statement": "AMD AI accelerator momentum, fractional IBI Smart position",
"thesis_type": "growth_momentum",
"entry_price": 142.10,
"entry_date": "2026-05-02",
"shares": 7.86,
"stop_price": 128.00
}python3 skills/trader-memory-core/scripts/trader_memory_cli.py ingest \
--source manual --input amd.json --state-dir state/theses/Required: ticker, thesis_statement, thesis_type (one of dividend_income, growth_momentum, mean_reversion, earnings_drift, pivot_breakout). stop_price/stop_loss and target_price/take_profit map to exit.stop_loss/exit.take_profit; entry_price/entry_date/shares are kept in origin.raw_provenance — the authoritative entry price/date and share count are set when you open the position (below). shares may be fractional (the schema accepts any positive number). Like every adapter, manual ingest creates an IDEA thesis only — it never mutates status directly.
To record an already-open broker position, run the explicit lifecycle sequence (the --event-date flags backdate the history so it stays chronological):
# 1. ingest → IDEA (stamped at entry_date)
python3 .../trader_memory_cli.py ingest --source manual --input amd.json --state-dir state/theses/
# 2. IDEA → ENTRY_READY (backdated)
python3 .../trader_memory_cli.py store --state-dir state/theses/ transition <id> ENTRY_READY \
--reason "existing IBI Smart position" --event-date 2026-05-02
# 3. ENTRY_READY → ACTIVE (fractional shares, backdated)
python3 .../trader_memory_cli.py store --state-dir state/theses/ open-position <id> \
--actual-price 142.10 --actual-date 2026-05-02 --shares 7.86 --event-date 2026-05-022. Query — Search and list theses
python3 skills/trader-memory-core/scripts/trader_memory_cli.py store \
--state-dir state/theses/ list --ticker AAPL --status ACTIVEFilter by --ticker, --status, or --type.
3. Update — Transition, attach position, link reports
Each lifecycle operation is available both as a Python function and as a thesis_store.py CLI subcommand. --event-date / --actual-date accept a plain YYYY-MM-DD (widened to midnight UTC) or a full ISO timestamp.
State transition (IDEA → ENTRY_READY only):
python3 skills/trader-memory-core/scripts/trader_memory_cli.py store --state-dir state/theses/ \
transition <id> ENTRY_READY --reason "validated" [--event-date YYYY-MM-DD]--event-date backdates status_history.at (use it when backfilling an existing position so the later backdated open-position stays chronological). Python: thesis_store.transition(state_dir, thesis_id, "ENTRY_READY", reason, event_date=...).
Open position (ENTRY_READY → ACTIVE — the only path to ACTIVE):
python3 .../trader_memory_cli.py store --state-dir state/theses/ open-position <id> \
--actual-price 142.10 --actual-date 2026-05-02 [--shares 7.86] [--event-date 2026-05-02]--shares accepts fractional quantities. Python: thesis_store.open_position(state_dir, thesis_id, actual_price, actual_date, shares=..., event_date=...).
Trim — partial close (ACTIVE/PARTIALLY_CLOSED → PARTIALLY_CLOSED, or → CLOSED when the whole remainder is sold):
python3 .../trader_memory_cli.py store --state-dir state/theses/ trim <id> \
--shares-sold 4 --price 120.00 --date 2026-05-10position.shares is the original opened quantity (immutable); position.shares_remaining tracks what is still open. Each trim appends a status_history ledger entry (shares_sold / price / proceeds / realized_pnl). outcome.pnl_dollars is the cumulative realized P&L (Σ all trims + final close); outcome.pnl_pct = pnl_dollars / (entry_price × original_shares) × 100. A trim that sells the entire remainder closes the thesis (default exit_reason: manual, overridable with --exit-reason). --date is the ledger timestamp (override with --event-date). Python: thesis_store.trim(state_dir, thesis_id, shares_sold, price, date, ...).
Status invariants: ACTIVE ⇒ shares_remaining == shares; PARTIALLY_CLOSED ⇒ 0 < shares_remaining < shares; CLOSED ⇒ shares_remaining == 0. Legacy theses (no shares_remaining) are treated as fully open at runtime.
Close or invalidate (→ CLOSED or INVALIDATED):
python3 .../trader_memory_cli.py store --state-dir state/theses/ close <id> \
--exit-reason target_hit --actual-price 165.00 --actual-date 2026-06-01
python3 .../trader_memory_cli.py store --state-dir state/theses/ terminate <id> \
--terminal-status INVALIDATED --exit-reason "thesis broke"close accepts an ACTIVE or PARTIALLY_CLOSED thesis; from PARTIALLY_CLOSED it adds the final leg and reports the cumulative outcome.
Python: thesis_store.terminate(state_dir, thesis_id, terminal_status, exit_reason, actual_price, actual_date). For CLOSED, delegates to close() which computes P&L (fractional-share aware). For INVALIDATED, P&L is computed if entry/exit prices are available.
Record review (any non-terminal):
Use thesis_store.mark_reviewed(state_dir, thesis_id, review_date=..., outcome="OK"|"WARN"|"REVIEW") to advance next_review_date and record alerts.
Attach position-sizer output:
python3 .../trader_memory_cli.py store --state-dir state/theses/ attach-position <id> \
--report reports/position_report.jsonPython: thesis_store.attach_position(state_dir, thesis_id, report_path) to link position sizing data. Validates that the report mode is "shares" (not budget).
Link related reports:
Use thesis_store.link_report(state_dir, thesis_id, skill, file, date) to cross-reference analysis documents.
4. Review — Check due dates and monitoring status
python3 skills/trader-memory-core/scripts/trader_memory_cli.py review \
--state-dir state/theses/ review-due --as-of 2026-04-15List theses with next_review_date <= as_of. Use with kanchi-dividend-review-monitor triggers (T1-T5) for systematic review.
5. Postmortem — Close and reflect
python3 skills/trader-memory-core/scripts/trader_memory_cli.py review \
--state-dir state/theses/ postmortem th_aapl_div_20260314_a3f1Generate a structured postmortem in state/journal/. If FMP API key is available, includes MAE/MFE (Maximum Adverse/Favorable Excursion) metrics.
Summary statistics:
python3 skills/trader-memory-core/scripts/trader_memory_cli.py review \
--state-dir state/theses/ summaryShows win rate, average P&L%, and per-type breakdown across all closed theses.
Output Format
Thesis YAML (state/theses/)
Each thesis is a YAML file with:
- Identity: thesis_id, ticker, created_at
- Classification: thesis_type, setup_type, catalyst
- Lifecycle: status, status_history
- Entry/Exit: target prices, actual prices, conditions
- Position: shares (fractional supported), value, risk (from position-sizer or
open-position --shares) - Monitoring: review dates, triggers, alerts
- Origin: source skill, screening grade, raw provenance
- Outcome: P&L, holding days, MAE/MFE, lessons learned
Index (state/theses/_index.json)
Lightweight index for fast queries without loading full YAML files.
Journal (state/journal/)
Postmortem markdown reports: pm_{thesis_id}.md.
Key Principles
- Forward-only transitions: IDEA → ENTRY_READY → ACTIVE → CLOSED (no backtracking)
- Raw provenance: All original screener data preserved in
origin.raw_provenance - Atomic writes: All file operations use tempfile + os.replace
- Git-tracked state:
state/directory is committed, providing audit trail - Phase 1 scope: Single-ticker theses only (pair trades and options in Phase 2)
Resources
references/thesis_lifecycle.md— Status states and valid transitionsreferences/field_mapping.md— Source skill → canonical field mappingschemas/thesis.schema.json— JSON Schema for thesis validation../../examples/workflows/trade-memory-loop/sample-run-full-path/— Worked end-to-end Plan → Trade → Record → Postmortem → Backtest → Journal example
<!-- Reference only. Actual rendering: thesis_review._render_postmortem() -->
Postmortem: {{ thesis_id }}
Ticker: {{ ticker }} Type: {{ thesis_type }} Status: {{ status }}
Thesis
{{ thesis_statement }}
Timeline
| Event | Date | Price |
|---|---|---|
| Created | {{ created_at }} | — |
| Entry | {{ entry_actual_date }} | {{ entry_actual_price }} |
| Exit | {{ exit_actual_date }} | {{ exit_actual_price }} |
Outcome
| Metric | Value |
|---|---|
| P&L ($) | {{ pnl_dollars }} |
| P&L (%) | {{ pnl_pct }} |
| Holding Days | {{ holding_days }} |
| Exit Reason | {{ exit_reason }} |
| MAE (%) | {{ mae_pct }} |
| MFE (%) | {{ mfe_pct }} |
Position
| Metric | Value |
|---|---|
| Shares | {{ shares }} |
| Position Value | {{ position_value }} |
| Risk ($) | {{ risk_dollars }} |
Evidence at Entry
{{ evidence_list }}
Kill Criteria
{{ kill_criteria_list }}
Lessons Learned
{{ lessons_learned }}
Field Mapping: Source Skill → Thesis Canonical Fields
Mapping Table
| Source Skill | Raw Field | Canonical Field | Notes |
|---|---|---|---|
| kanchi-dividend-sop | ticker | ticker | Direct |
| kanchi-dividend-sop | buy_target_price | entry.target_price | |
| kanchi-dividend-sop | current_yield_pct | origin.raw_provenance.current_yield_pct | Preserved in raw |
| kanchi-dividend-sop | signal | origin.raw_provenance.signal | Preserved in raw |
| earnings-trade-analyzer | symbol | ticker | Renamed |
| earnings-trade-analyzer | grade | origin.screening_grade | A/B/C/D |
| earnings-trade-analyzer | composite_score | origin.screening_score | 0-100 |
| earnings-trade-analyzer | gap_pct | origin.raw_provenance.gap_pct | Preserved in raw |
| earnings-trade-analyzer | sector | market_context.sector | |
| vcp-screener | symbol | ticker | Renamed |
| vcp-screener | entry_ready | origin.raw_provenance.entry_ready | Boolean |
| vcp-screener | distance_from_pivot_pct | origin.raw_provenance.distance_from_pivot_pct | |
| vcp-screener | composite_score | origin.screening_score | |
| pead-screener | symbol | ticker | Renamed |
| pead-screener | entry_price | entry.target_price | |
| pead-screener | stop_loss | exit.stop_loss | |
| pead-screener | status | origin.raw_provenance.pead_status | SIGNAL_READY/BREAKOUT/etc |
| canslim-screener | symbol | ticker | Renamed |
| canslim-screener | rating | origin.screening_grade | |
| canslim-screener | composite_score | origin.screening_score | |
| edge-candidate-agent | id | origin.raw_provenance.edge_id | |
| edge-candidate-agent | hypothesis_type | origin.raw_provenance.hypothesis_type | |
| edge-candidate-agent | mechanism_tag | mechanism_tag | behavior/structure/uncertain |
| manual | ticker | ticker | Required |
| manual | thesis_statement | thesis_statement | Required |
| manual | thesis_type | thesis_type | Required; must be a valid enum value |
| manual | stop_price / stop_loss | exit.stop_loss | Optional |
| manual | target_price / take_profit | exit.take_profit | Optional |
| manual | entry_price | origin.raw_provenance.entry_price | Authoritative entry.actual_price set by open-position |
| manual | entry_date | origin.raw_provenance.entry_date | Also drives _source_date (date-only [:10]) so the IDEA stamp is backdated |
| manual | shares | origin.raw_provenance.shares | Fractional ok; authoritative position.shares set by open-position |
| manual | setup_type | setup_type | Optional passthrough |
| manual | (all other keys) | origin.raw_provenance.* | Preserved |
Position Sizer (Update Operation, not Register)
| Raw Field | Canonical Field | Notes |
|---|---|---|
final_recommended_shares | position.shares + position.shares_remaining | shares_remaining seeded == shares |
final_position_value | position.position_value | |
final_risk_dollars | position.risk_dollars | |
final_risk_pct | position.risk_pct_of_account | |
mode | — | Must be "shares" (budget mode rejected) |
position.shares is schema type number, exclusiveMinimum: 0 — fractional shares are valid (IBKR / Robinhood / IBI Smart / Alpaca etc.). Existing integer-share theses remain valid (number ⊇ integer).
Partial close (trim)
position.shares = the original opened quantity (immutable). position.shares_remaining (number, minimum: 0) = currently-open quantity. Each trim() and the final close write a status_history ledger entry:
| Ledger field (status_history item) | Meaning |
|---|---|
shares_sold | quantity sold in this leg |
price | execution price of this leg |
proceeds | round(price × shares_sold, 2) |
realized_pnl | round((price − entry_price) × shares_sold, 2) |
outcome.pnl_dollars = Σ realized_pnl over all ledger entries; outcome.pnl_pct = pnl_dollars / (entry_price × original_shares) × 100. The ledger fields are optional in the schema, so legacy (non-trim) status_history entries stay valid; shares_remaining is optional too (absent ⇒ treated as fully open for legacy ACTIVE/CLOSED).
Manual Entry (free-form, non-screener)
The manual source ingests hand-entered positions. Input is free-form JSON — a single object or an array. Like every adapter it creates an IDEA thesis only; the authoritative entry price/date and (fractional) share count are set later by the open-position lifecycle step, not at ingest. entry_date is normalized to a date-only _source_date so the IDEA status_history entry is stamped at the entry date — keeping a backdated IDEA → ENTRY_READY → ACTIVE chain chronological.
Phase 1 Constraints
- Single ticker only: Each thesis tracks exactly one stock symbol
- edge-candidate-agent: Only tickets with
research_only=Falseand a singleticker/symbolfield are accepted.MARKET_BASKETorresearch_onlytickets are skipped with a warning log. - pair-trade-screener and options-strategy-advisor are Phase 2 (multi-leg)
Raw Provenance
All adapter-specific fields not listed in the canonical mapping are preserved in origin.raw_provenance. This allows: 1. No data loss during transformation 2. Recovery of original values if canonical mapping changes 3. Skill-specific analysis using raw data
Thesis Lifecycle
Status States
| Status | Description | Typical Trigger |
|---|---|---|
IDEA | Screened candidate, not yet validated for entry | Ingest from screener output |
ENTRY_READY | Validated, entry conditions defined, waiting for price | Manual review / deep-dive analysis |
ACTIVE | Position opened (actual_price and actual_date filled); shares_remaining == shares | Entry execution confirmed |
PARTIALLY_CLOSED | Part of the position trimmed; 0 < shares_remaining < shares | trim() sold some shares |
CLOSED | Position fully exited, outcome recorded; shares_remaining == 0 | Exit execution confirmed |
INVALIDATED | Thesis killed before or during holding | Kill criteria triggered |
Valid Transitions
IDEA ─► ENTRY_READY ─► ACTIVE ─► PARTIALLY_CLOSED ─► CLOSED
│ │ │ │ ▲ │
│ │ │ │ └───┘ (further trims)
└──────────┴────────────┴────────────┴───────────► INVALIDATEDACTIVE → PARTIALLY_CLOSED → … → CLOSED is driven by trim() only; trim() that sells the entire remainder goes straight to CLOSED. close() accepts ACTIVE or PARTIALLY_CLOSED.
Partial close & cumulative P&L
position.shares is the original opened quantity (immutable); position.shares_remaining is the currently-open quantity. Each trim() and the final close append a status_history ledger entry carrying shares_sold / price / proceeds / realized_pnl. outcome.pnl_dollars = Σ realized_pnl; outcome.pnl_pct = pnl_dollars / (entry_price × original_shares) × 100. With no trims this is identical to the legacy single-leg result. Legacy ACTIVE/CLOSED theses missing shares_remaining validate fine and are treated as fully open at runtime; PARTIALLY_CLOSED (a post-PR-80B status) always requires position.shares + position.shares_remaining.
Forward-Only Rule
Transitions must move forward in the lifecycle. Reverse transitions are not allowed:
ACTIVE → IDEA— blocked (ValueError)CLOSED → ACTIVE— blocked (ValueError)INVALIDATED → *— blocked (terminal state)
Any → INVALIDATED
Any non-terminal status can transition to INVALIDATED:
IDEA → INVALIDATED(screener output invalidated before review)ENTRY_READY → INVALIDATED(kill criteria triggered before entry)ACTIVE → INVALIDATED(kill criteria triggered during holding)
Status-Dependent Operations
| Operation | Required Status | Effect |
|---|---|---|
register() | — | Creates thesis with IDEA status (idempotent via fingerprint) |
transition() | Any non-terminal (IDEA → ENTRY_READY only) | Advances status, appends to status_history |
open_position() | ENTRY_READY | Sets entry data + shares_remaining, transitions to ACTIVE (only path to ACTIVE) |
attach_position() | IDEA / ENTRY_READY / ACTIVE | Attaches position sizing data (sets shares + shares_remaining == shares); rejected on PARTIALLY_CLOSED / CLOSED / INVALIDATED (would corrupt the trim ledger) |
trim() | ACTIVE / PARTIALLY_CLOSED | Sells part; appends a ledger entry, decrements shares_remaining; → PARTIALLY_CLOSED (or CLOSED if remainder hits 0) |
link_report() | Any | Adds linked report reference |
close() | ACTIVE / PARTIALLY_CLOSED | Sets CLOSED, computes cumulative outcome.pnl_* and holding_days |
terminate() | Any non-terminal | Transitions to CLOSED (delegates to close) or INVALIDATED with optional exit data |
mark_reviewed() | Any non-terminal | Updates review dates and status based on review_date |
rebuild_index() | — | Recreates _index.json from YAML files |
validate_state() | — | Checks file ⇔ index consistency + schema validation |
Important:
transition()only allowsIDEA → ENTRY_READY;ACTIVEand
PARTIALLY_CLOSED are blocked (use open_position() / trim()), as are all terminal statuses.
- Use
open_position()to reachACTIVE(requiresactual_priceandactual_date). - Use
trim()to reachPARTIALLY_CLOSED(requiresshares_sold,price,date). - Use
close()(fromACTIVEorPARTIALLY_CLOSED),trim()selling the
whole remainder, or terminate(terminal_status="CLOSED") to reach CLOSED.
- Use
terminate(terminal_status="INVALIDATED")to reachINVALIDATED
(cumulative P&L when price+date supplied; otherwise the legacy no-P&L path).
CLI Access
Every lifecycle operation is also a thesis_store.py subcommand: transition, open-position, attach-position, close, terminate (alongside list / get / review-due / rebuild-index / doctor / mark-reviewed). No Python required to walk a thesis through its lifecycle.
Backdating an existing position (--event-date)
transition, open-position, close, and terminate accept --event-date (sets that transition's status_history.at). open-position also takes --actual-date (→ entry.actual_date); close/terminate take --actual-date (→ exit.actual_date). A plain YYYY-MM-DD is widened to midnight UTC; a full ISO timestamp passes through.
transition --event-date exists specifically so an already-open broker position recorded via the manual adapter keeps a chronological history: the manual adapter backdates the IDEA stamp to entry_date, then transition --event-date <entry_date> and open-position --event-date <entry_date> keep ENTRY_READY and ACTIVE at the same date. Without transition --event-date, ENTRY_READY would be stamped "now" while a backdated open-position --event-date puts ACTIVE in the past — so ACTIVE lands before ENTRY_READY, failing the status_history monotonicity check on save.
Monitoring Cycle
1. On register(): next_review_date = created_at + review_interval_days 2. On review: last_review_date updated, next_review_date advanced 3. list_review_due(as_of) returns theses where next_review_date <= as_of 4. Review status: OK → WARN → REVIEW (escalation ladder)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "thesis.schema.json",
"title": "Trader Memory Core Thesis",
"description": "Schema for a single-ticker investment thesis tracked by Trader Memory Core.",
"type": "object",
"required": [
"thesis_id",
"ticker",
"created_at",
"updated_at",
"thesis_type",
"status",
"status_history",
"thesis_statement",
"origin"
],
"properties": {
"thesis_id": {
"type": "string",
"pattern": "^th_[A-Za-z0-9]+_[a-z]+_[0-9]{8}_[a-f0-9]{4}$",
"description": "Unique ID: th_{ticker}_{type_abbr}_{date}_{hash4}"
},
"ticker": {
"type": "string",
"minLength": 1,
"description": "Single stock ticker symbol (Phase 1: no multi-leg)"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"thesis_type": {
"type": "string",
"enum": [
"dividend_income",
"growth_momentum",
"mean_reversion",
"earnings_drift",
"pivot_breakout"
]
},
"setup_type": {
"type": ["string", "null"],
"description": "Specific setup pattern (e.g., pullback_to_5y_avg_yield)"
},
"catalyst": {
"type": ["string", "null"],
"description": "Catalyst or event driving the thesis"
},
"status": {
"type": "string",
"enum": ["IDEA", "ENTRY_READY", "ACTIVE", "PARTIALLY_CLOSED", "CLOSED", "INVALIDATED"]
},
"status_history": {
"type": "array",
"items": {
"type": "object",
"required": ["status", "at", "reason"],
"properties": {
"status": {
"type": "string",
"enum": ["IDEA", "ENTRY_READY", "ACTIVE", "PARTIALLY_CLOSED", "CLOSED", "INVALIDATED"]
},
"at": {
"type": "string",
"format": "date-time"
},
"reason": {
"type": "string"
},
"shares_sold": {"type": "number", "exclusiveMinimum": 0},
"price": {"type": "number"},
"proceeds": {"type": "number"},
"realized_pnl": {"type": "number"}
}
}
},
"thesis_statement": {
"type": "string",
"minLength": 1
},
"mechanism_tag": {
"type": ["string", "null"],
"enum": ["behavior", "structure", "uncertain", null]
},
"evidence": {
"type": "array",
"items": {"type": "string"},
"default": []
},
"kill_criteria": {
"type": "array",
"items": {"type": "string"},
"default": []
},
"confidence": {
"type": ["string", "null"],
"description": "Qualitative confidence level (matches hypothesis_card.schema.json)"
},
"confidence_score": {
"type": ["number", "null"],
"minimum": 0,
"maximum": 1
},
"entry": {
"type": "object",
"properties": {
"target_price": {"type": ["number", "null"]},
"conditions": {
"type": "array",
"items": {"type": "string"},
"default": []
},
"actual_price": {"type": ["number", "null"]},
"actual_date": {"type": ["string", "null"], "format": "date-time"}
},
"additionalProperties": false
},
"exit": {
"type": "object",
"properties": {
"stop_loss": {"type": ["number", "null"]},
"stop_loss_pct": {"type": ["number", "null"]},
"take_profit": {"type": ["number", "null"]},
"take_profit_rr": {"type": ["number", "null"]},
"time_stop_days": {"type": ["integer", "null"]},
"actual_price": {"type": ["number", "null"]},
"actual_date": {"type": ["string", "null"], "format": "date-time"},
"exit_reason": {
"type": ["string", "null"],
"enum": ["stop_hit", "target_hit", "time_stop", "invalidated", "manual", null]
}
},
"additionalProperties": false
},
"position": {
"type": ["object", "null"],
"properties": {
"shares": {"type": "number", "exclusiveMinimum": 0},
"shares_remaining": {"type": "number", "minimum": 0},
"position_value": {"type": "number"},
"risk_dollars": {"type": "number"},
"risk_pct_of_account": {"type": ["number", "null"]},
"account_type": {"type": ["string", "null"]},
"sizing_method": {"type": ["string", "null"]},
"raw_source": {
"type": "object",
"properties": {
"skill": {"type": "string"},
"file": {"type": "string"},
"fields": {"type": "object"}
}
}
}
},
"market_context": {
"type": ["object", "null"],
"properties": {
"regime": {"type": ["string", "null"]},
"breadth_score": {"type": ["number", "null"]},
"top_detector_score": {"type": ["number", "null"]},
"sector": {"type": ["string", "null"]}
}
},
"monitoring": {
"type": "object",
"properties": {
"review_interval_days": {"type": "integer", "minimum": 1, "default": 30},
"next_review_date": {"type": ["string", "null"], "format": "date"},
"last_review_date": {"type": ["string", "null"], "format": "date"},
"review_status": {
"type": "string",
"enum": ["OK", "WARN", "REVIEW"],
"default": "OK"
},
"triggers_config": {
"type": "array",
"items": {
"type": "object",
"properties": {
"trigger": {"type": "string"},
"description": {"type": "string"}
}
},
"default": []
},
"alerts": {
"type": "array",
"items": {"type": "string"},
"default": []
}
}
},
"origin_fingerprint": {
"type": ["string", "null"],
"description": "Deterministic hash for deduplication (sha256[:16])"
},
"origin": {
"type": "object",
"required": ["skill", "output_file"],
"properties": {
"skill": {"type": "string"},
"output_file": {"type": "string"},
"screening_grade": {"type": ["string", "null"]},
"screening_score": {"type": ["number", "null"]},
"raw_provenance": {"type": "object", "default": {}}
}
},
"linked_reports": {
"type": "array",
"items": {
"type": "object",
"required": ["skill", "file", "date"],
"properties": {
"skill": {"type": "string"},
"file": {"type": "string"},
"date": {"type": "string", "format": "date"}
}
},
"default": []
},
"outcome": {
"type": "object",
"properties": {
"pnl_dollars": {"type": ["number", "null"]},
"pnl_pct": {"type": ["number", "null"]},
"holding_days": {"type": ["integer", "null"]},
"mae_pct": {"type": ["number", "null"]},
"mfe_pct": {"type": ["number", "null"]},
"mae_mfe_source": {"type": ["string", "null"]},
"lessons_learned": {"type": ["string", "null"]}
}
}
},
"additionalProperties": false
}
"""Thin FMP price adapter for MAE/MFE calculation.
Single-purpose: fetch daily close prices. Does not reuse existing
fmp_client modules (which vary in return shape across skills).
"""
from __future__ import annotations
import json
import logging
import os
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
_FMP_HIST_ENDPOINTS = [
("https://financialmodelingprep.com/stable/historical-price-eod/full", True),
("https://financialmodelingprep.com/api/v3/historical-price-full", False),
]
class FMPPriceAdapter:
"""Fetch daily adjusted close prices from FMP API."""
def __init__(self, api_key: str | None = None):
self.api_key = api_key or os.environ.get("FMP_API_KEY")
if not self.api_key:
raise ValueError("FMP API key required. Set FMP_API_KEY env var or pass api_key.")
def get_daily_closes(self, ticker: str, from_date: str, to_date: str) -> list[dict]:
"""Return daily close prices, oldest first.
Args:
ticker: Stock symbol (e.g., "AAPL").
from_date: Start date "YYYY-MM-DD".
to_date: End date "YYYY-MM-DD".
Returns:
List of {"date": "YYYY-MM-DD", "close": float}, oldest first.
Raises:
urllib.error.URLError: On network/API errors (only if all endpoints fail).
ValueError: On invalid response.
"""
last_error = None
for base_url, is_stable in _FMP_HIST_ENDPOINTS:
if is_stable:
url = f"{base_url}?symbol={ticker}&from={from_date}&to={to_date}"
else:
url = f"{base_url}/{ticker}?from={from_date}&to={to_date}"
req = urllib.request.Request(url, headers={"apikey": self.api_key})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
last_error = e
logger.debug("FMP endpoint %s failed for %s: %s", base_url, ticker, e)
continue
historical = self._extract_historical(data, ticker)
if not historical:
continue
# FMP returns newest first; reverse to oldest first.
# Stable EOD endpoint no longer exposes `adjClose`; fall back to `close`.
# Patched 2026-05-22 (stable shape).
result = [
{"date": item["date"], "close": item.get("adjClose") or item["close"]}
for item in reversed(historical)
if "date" in item and ("adjClose" in item or "close" in item)
]
return result
if last_error:
logger.error("FMP API error for %s: %s", ticker, last_error)
raise last_error
logger.warning("No price data returned for %s (%s to %s)", ticker, from_date, to_date)
return []
@staticmethod
def _extract_historical(data, ticker: str) -> list[dict]:
"""Extract historical array from FMP response (stable list / v3 dict)."""
# New stable EOD endpoint returns a flat list of dicts directly.
# Patched 2026-05-22 (stable shape).
if isinstance(data, list):
norm = ticker.replace("-", ".")
return [
row
for row in data
if isinstance(row, dict) and row.get("symbol", ticker).replace("-", ".") == norm
]
if not isinstance(data, dict):
return []
if "historical" in data:
return data["historical"]
if "historicalStockList" in data:
norm = ticker.replace("-", ".")
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == norm:
return entry.get("historical", [])
return []
"""Shared fixtures for trader-memory-core tests."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
"""Tests for fmp_price_adapter.py — mock HTTP responses."""
import json
from unittest.mock import MagicMock, patch
import fmp_price_adapter
import pytest
def test_get_daily_closes_parses_response():
"""Mock FMP response → parsed correctly, oldest first."""
mock_response_data = {
"symbol": "AAPL",
"historical": [
{"date": "2026-03-03", "adjClose": 152.0, "close": 152.0},
{"date": "2026-03-02", "adjClose": 150.5, "close": 150.5},
{"date": "2026-03-01", "adjClose": 149.0, "close": 149.0},
],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(mock_response_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
adapter = fmp_price_adapter.FMPPriceAdapter(api_key="test_key")
result = adapter.get_daily_closes("AAPL", "2026-03-01", "2026-03-03")
assert len(result) == 3
# Oldest first
assert result[0]["date"] == "2026-03-01"
assert result[0]["close"] == 149.0
assert result[2]["date"] == "2026-03-03"
assert result[2]["close"] == 152.0
def test_get_daily_closes_empty_response():
"""Empty historical data → empty list."""
mock_response_data = {"symbol": "XYZ", "historical": []}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(mock_response_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
adapter = fmp_price_adapter.FMPPriceAdapter(api_key="test_key")
result = adapter.get_daily_closes("XYZ", "2026-03-01", "2026-03-03")
assert result == []
def test_uses_apikey_header():
"""FMP adapter should use 'apikey' header (not 'Authorization')."""
mock_response_data = {"symbol": "AAPL", "historical": []}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(mock_response_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen:
adapter = fmp_price_adapter.FMPPriceAdapter(api_key="my_test_key")
adapter.get_daily_closes("AAPL", "2026-03-01", "2026-03-03")
req = mock_urlopen.call_args[0][0]
assert req.get_header("Apikey") == "my_test_key"
assert req.get_header("Authorization") is None
def test_no_api_key_raises():
"""Missing API key should raise ValueError."""
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(ValueError, match="FMP API key required"):
fmp_price_adapter.FMPPriceAdapter(api_key=None)
"""Tests for thesis_ingest.py — adapter conversion and registration."""
import json
from pathlib import Path
import pytest
import thesis_ingest
import thesis_store
# -- Helpers -------------------------------------------------------------------
def _write_json(tmp_path: Path, data, filename="input.json"):
path = tmp_path / filename
path.write_text(json.dumps(data))
return str(path)
def _write_text(tmp_path: Path, text: str, filename="input.csv"):
path = tmp_path / filename
path.write_text(text)
return str(path)
# -- Tests: kanchi adapter -----------------------------------------------------
def test_ingest_kanchi(tmp_path: Path):
"""kanchi JSON → thesis with dividend_income, entry.target_price populated."""
state_dir = tmp_path / "theses"
record = {
"ticker": "JNJ",
"buy_target_price": 148.50,
"current_yield_pct": 3.2,
"signal": "BUY",
"grade": "A",
}
input_file = _write_json(tmp_path, {"candidates": [record]})
ids = thesis_ingest.ingest("kanchi-dividend-sop", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["ticker"] == "JNJ"
assert thesis["thesis_type"] == "dividend_income"
assert thesis["entry"]["target_price"] == 148.50
assert thesis["origin"]["skill"] == "kanchi-dividend-sop"
assert thesis["origin"]["raw_provenance"]["current_yield_pct"] == 3.2
# -- Tests: earnings adapter ---------------------------------------------------
def test_ingest_earnings(tmp_path: Path):
"""earnings JSON → grade in raw_provenance, screening_grade canonical."""
state_dir = tmp_path / "theses"
record = {
"symbol": "NVDA",
"grade": "A",
"composite_score": 92.5,
"gap_pct": 8.3,
"sector": "Technology",
}
input_file = _write_json(tmp_path, {"results": [record]})
ids = thesis_ingest.ingest("earnings-trade-analyzer", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["ticker"] == "NVDA"
assert thesis["thesis_type"] == "earnings_drift"
assert thesis["origin"]["screening_grade"] == "A"
assert thesis["origin"]["screening_score"] == 92.5
assert thesis["origin"]["raw_provenance"]["gap_pct"] == 8.3
assert thesis["market_context"]["sector"] == "Technology"
# -- Tests: vcp adapter --------------------------------------------------------
def test_ingest_vcp(tmp_path: Path):
"""vcp JSON → pivot_breakout type."""
state_dir = tmp_path / "theses"
record = {
"symbol": "PLTR",
"distance_from_pivot_pct": 2.3,
"entry_ready": True,
"composite_score": 78.0,
}
input_file = _write_json(tmp_path, {"results": [record]})
ids = thesis_ingest.ingest("vcp-screener", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["ticker"] == "PLTR"
assert thesis["thesis_type"] == "pivot_breakout"
assert thesis["origin"]["raw_provenance"]["entry_ready"] is True
# -- Tests: pead adapter -------------------------------------------------------
def test_ingest_pead(tmp_path: Path):
"""pead JSON → entry_price and stop_loss mapped."""
state_dir = tmp_path / "theses"
record = {
"symbol": "CRWD",
"entry_price": 380.00,
"stop_loss": 355.00,
"status": "SIGNAL_READY",
"grade": "B",
}
input_file = _write_json(tmp_path, {"results": [record]})
ids = thesis_ingest.ingest("pead-screener", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["entry"]["target_price"] == 380.00
assert thesis["exit"]["stop_loss"] == 355.00
# -- Tests: canslim adapter ----------------------------------------------------
def test_ingest_canslim(tmp_path: Path):
"""canslim JSON → growth_momentum type."""
state_dir = tmp_path / "theses"
record = {
"symbol": "META",
"rating": "A",
"composite_score": 85.0,
}
input_file = _write_json(tmp_path, [record])
ids = thesis_ingest.ingest("canslim-screener", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["thesis_type"] == "growth_momentum"
assert thesis["origin"]["screening_grade"] == "A"
# -- Tests: raw_provenance preserved -------------------------------------------
def test_all_adapters_preserve_raw_provenance(tmp_path: Path):
"""All adapters should preserve original data in raw_provenance."""
state_dir = tmp_path / "theses"
record = {
"symbol": "TEST",
"grade": "B",
"composite_score": 70.0,
"custom_field": "custom_value",
}
input_file = _write_json(tmp_path, {"results": [record]})
ids = thesis_ingest.ingest("earnings-trade-analyzer", input_file, str(state_dir))
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["origin"]["raw_provenance"]["custom_field"] == "custom_value"
# -- Tests: error handling -----------------------------------------------------
def test_unknown_source_raises(tmp_path: Path):
"""Unknown --source should raise ValueError."""
input_file = _write_json(tmp_path, [{"ticker": "AAPL"}])
with pytest.raises(ValueError, match="Unknown source"):
thesis_ingest.ingest("nonexistent-skill", input_file, str(tmp_path))
def test_missing_required_fields_raises(tmp_path: Path):
"""Missing required fields should raise validation error."""
state_dir = tmp_path / "theses"
record = {"not_a_ticker": "AAPL"} # missing 'ticker' or 'symbol'
input_file = _write_json(tmp_path, {"results": [record]})
# Should log error but not raise (continues to next record)
ids = thesis_ingest.ingest("earnings-trade-analyzer", input_file, str(state_dir))
assert len(ids) == 0
# -- Tests: edge adapter -------------------------------------------------------
def test_edge_research_only_skipped(tmp_path: Path):
"""edge ticket with research_only=True → skip with warning."""
state_dir = tmp_path / "theses"
record = {
"id": "ticket_001",
"ticker": "SPY",
"hypothesis_type": "breakout",
"research_only": True,
}
input_file = _write_json(tmp_path, record)
ids = thesis_ingest.ingest("edge-candidate-agent", input_file, str(state_dir))
assert len(ids) == 0
def test_edge_market_basket_skipped(tmp_path: Path):
"""edge ticket with MARKET_BASKET → skip with warning."""
state_dir = tmp_path / "theses"
record = {
"id": "ticket_002",
"universe": "MARKET_BASKET",
"hypothesis_type": "momentum",
}
input_file = _write_json(tmp_path, record)
ids = thesis_ingest.ingest("edge-candidate-agent", input_file, str(state_dir))
assert len(ids) == 0
# -- Tests: fix verification ---------------------------------------------------
def test_ingest_kanchi_rows_key(tmp_path: Path):
"""kanchi build_entry_signals.py uses 'rows' key, not 'candidates'."""
state_dir = tmp_path / "theses"
record = {"ticker": "PG", "buy_target_price": 165.00}
input_file = _write_json(tmp_path, {"rows": [record]})
ids = thesis_ingest.ingest("kanchi-dividend-sop", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["ticker"] == "PG"
assert thesis["entry"]["target_price"] == 165.00
def test_edge_ticket_top_level_entry_exit(tmp_path: Path):
"""edge ticket uses top-level entry/exit, not signals.entry."""
state_dir = tmp_path / "theses"
record = {
"id": "edge_vcp_v1",
"ticker": "AMZN",
"hypothesis_type": "breakout",
"entry_family": "pivot_breakout",
"mechanism_tag": "behavior",
"entry": {"conditions": ["breakout above pivot", "volume > 1.5x avg"]},
"exit": {"stop_loss_pct": 0.07, "take_profit_rr": 2.0, "time_stop_days": 20},
}
input_file = _write_json(tmp_path, record)
ids = thesis_ingest.ingest("edge-candidate-agent", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["entry"]["conditions"] == ["breakout above pivot", "volume > 1.5x avg"]
assert thesis["exit"]["stop_loss_pct"] == 0.07
assert thesis["exit"]["take_profit_rr"] == 2.0
assert thesis["exit"]["time_stop_days"] == 20
# -- Tests: source date propagation --------------------------------------------
def test_ingest_propagates_as_of_date(tmp_path: Path):
"""as_of from report metadata should become thesis_id date and created_at."""
state_dir = tmp_path / "theses"
data = {
"as_of": "2026-02-20",
"generated_at": "2026-02-20T10:00:00Z",
"rows": [{"ticker": "KO", "buy_target_price": 60.00}],
}
input_file = _write_json(tmp_path, data)
ids = thesis_ingest.ingest("kanchi-dividend-sop", input_file, str(state_dir))
assert len(ids) == 1
assert "_20260220_" in ids[0]
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["created_at"].startswith("2026-02-20")
def test_ingest_uses_generated_at_as_fallback(tmp_path: Path):
"""generated_at should be used if as_of is absent."""
state_dir = tmp_path / "theses"
data = {
"generated_at": "2026-01-10T08:30:00Z",
"results": [{"symbol": "GOOG", "grade": "B", "composite_score": 72.0}],
}
input_file = _write_json(tmp_path, data)
ids = thesis_ingest.ingest("earnings-trade-analyzer", input_file, str(state_dir))
assert "_20260110_" in ids[0]
# -- Tests: duplicate handling -------------------------------------------------
def test_duplicate_ingest_is_idempotent(tmp_path: Path):
"""Same input ingested twice should return the same thesis_id (idempotent)."""
state_dir = tmp_path / "theses"
record = {"symbol": "AAPL", "grade": "A", "composite_score": 90.0}
input_file = _write_json(tmp_path, {"results": [record]})
ids1 = thesis_ingest.ingest("earnings-trade-analyzer", input_file, str(state_dir))
ids2 = thesis_ingest.ingest("earnings-trade-analyzer", input_file, str(state_dir))
assert ids1[0] == ids2[0]
# -- Tests: manual adapter -----------------------------------------------------
def test_ingest_manual_single_record_fractional(tmp_path: Path):
"""A free-form single dict (no results/candidates wrapper) → schema-valid
IDEA thesis; fractional shares preserved in raw_provenance; stop/target
land in existing exit.* fields; entry.* left empty."""
state_dir = tmp_path / "theses"
record = {
"ticker": "AMD",
"thesis_statement": "AMD AI accelerator momentum, fractional IBI Smart position",
"thesis_type": "growth_momentum",
"entry_price": 142.10,
"entry_date": "2026-05-02",
"shares": 7.86,
"stop_price": 128.0,
"target_price": 180.0,
}
input_file = _write_json(tmp_path, record)
ids = thesis_ingest.ingest("manual", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0]) # get() implies schema-valid
assert thesis["status"] == "IDEA"
assert thesis["ticker"] == "AMD"
assert thesis["thesis_type"] == "growth_momentum"
assert thesis["origin"]["skill"] == "manual"
assert thesis["origin"]["raw_provenance"]["shares"] == 7.86
assert thesis["origin"]["raw_provenance"]["entry_price"] == 142.10
assert thesis["exit"]["stop_loss"] == 128.0
assert thesis["exit"]["take_profit"] == 180.0
# entry_price/date NOT mapped to entry.* (set later by open-position)
assert thesis["entry"].get("actual_price") is None
assert thesis["entry"].get("target_price") is None
assert thesis["position"] is None
# _source_date from entry_date → IDEA history stamped that day
assert thesis["status_history"][0]["at"] == "2026-05-02T00:00:00+00:00"
assert "_20260502_" in ids[0]
def test_ingest_manual_array(tmp_path: Path):
"""A list of manual records registers N theses."""
state_dir = tmp_path / "theses"
records = [
{
"ticker": "TSLA",
"thesis_statement": "TSLA swing",
"thesis_type": "growth_momentum",
"entry_date": "2026-05-02",
},
{
"ticker": "OIH",
"thesis_statement": "OIH energy services",
"thesis_type": "mean_reversion",
"entry_date": "2026-05-03",
},
]
input_file = _write_json(tmp_path, records)
ids = thesis_ingest.ingest("manual", input_file, str(state_dir))
assert len(ids) == 2
tickers = {thesis_store.get(state_dir, i)["ticker"] for i in ids}
assert tickers == {"TSLA", "OIH"}
def test_ingest_manual_bulk_csv_success(tmp_path: Path):
"""Manual bulk CSV registers all rows and preserves provenance-only fields."""
state_dir = tmp_path / "theses"
csv_file = _write_text(
tmp_path,
"\n".join(
[
"ticker,thesis_statement,thesis_type,entry_price,entry_date,shares,stop_price,stop_loss,target_price,take_profit,notes",
"TSLA,TSLA swing,growth_momentum,180.5,2026-05-02,1.5,160,155,220,230,watch volume",
"OIH,OIH energy services,mean_reversion,280,2026-05-03,2,,,,,sector rebound",
]
),
)
ids = thesis_ingest.ingest_bulk_csv("manual", csv_file, str(state_dir))
assert len(ids) == 2
tsla = thesis_store.get(state_dir, ids[0])
assert tsla["ticker"] == "TSLA"
# Existing manual alias precedence: stop_loss/take_profit win when both are supplied.
assert tsla["exit"]["stop_loss"] == 155.0
assert tsla["exit"]["take_profit"] == 230.0
assert tsla["origin"]["raw_provenance"]["entry_price"] == 180.5
assert tsla["origin"]["raw_provenance"]["shares"] == 1.5
assert tsla["origin"]["raw_provenance"]["notes"] == "watch volume"
assert tsla["position"] is None
def test_ingest_manual_bulk_csv_missing_required_column(tmp_path: Path):
"""Bulk CSV should fail clearly when required columns are absent."""
csv_file = _write_text(tmp_path, "ticker,thesis_statement\nAMD,AMD thesis\n")
with pytest.raises(ValueError, match="missing required column"):
thesis_ingest.ingest_bulk_csv("manual", csv_file, str(tmp_path / "theses"))
def test_ingest_manual_bulk_csv_invalid_row_registers_zero(tmp_path: Path):
"""Bulk CSV is all-or-nothing: no rows are persisted after validation failure."""
state_dir = tmp_path / "theses"
csv_file = _write_text(
tmp_path,
"\n".join(
[
"ticker,thesis_statement,thesis_type,entry_date",
"AMD,AMD thesis,growth_momentum,2026-05-02",
"NVDA,NVDA thesis,not_a_type,2026-05-03",
]
),
)
with pytest.raises(ValueError, match="row 3"):
thesis_ingest.ingest_bulk_csv("manual", csv_file, str(state_dir))
assert not list(state_dir.glob("th_*.yaml"))
def test_ingest_manual_bulk_csv_source_must_be_manual(tmp_path: Path):
csv_file = _write_text(
tmp_path,
"ticker,thesis_statement,thesis_type\nAMD,AMD thesis,growth_momentum\n",
)
with pytest.raises(ValueError, match="only supported with --source manual"):
thesis_ingest.ingest_bulk_csv("earnings-trade-analyzer", csv_file, str(tmp_path))
def test_ingest_manual_missing_ticker_reaches_adapter(tmp_path: Path):
"""A dict with no ticker/id/symbol still reaches the manual adapter
(source-aware _extract_records) and yields the clear field error; ingest
registers 0."""
state_dir = tmp_path / "theses"
input_file = _write_json(
tmp_path, {"thesis_statement": "no ticker", "thesis_type": "growth_momentum"}
)
ids = thesis_ingest.ingest("manual", input_file, str(state_dir))
assert ids == [] # adapter raised, ingest logged + skipped
# The adapter's message is explicit (not the generic _extract_records one)
with pytest.raises(ValueError, match="Missing required field 'ticker'"):
thesis_ingest.ingest_manual(
{"thesis_statement": "x", "thesis_type": "growth_momentum"}, "f.json"
)
def test_ingest_manual_invalid_thesis_type(tmp_path: Path):
"""Bad thesis_type → clear error, 0 registered."""
state_dir = tmp_path / "theses"
input_file = _write_json(
tmp_path,
{"ticker": "AMD", "thesis_statement": "x", "thesis_type": "not_a_type"},
)
ids = thesis_ingest.ingest("manual", input_file, str(state_dir))
assert ids == []
with pytest.raises(ValueError, match="Invalid or missing 'thesis_type'"):
thesis_ingest.ingest_manual(
{"ticker": "AMD", "thesis_statement": "x", "thesis_type": "not_a_type"},
"f.json",
)
@pytest.mark.parametrize("entry_date", ["2026-05-02", "2026-05-02T10:00:00+00:00"])
def test_ingest_manual_source_date_normalized(tmp_path: Path, entry_date: str):
"""Date-only and full-ISO entry_date both yield a date-only _source_date
(register() builds 'YYYY-MM-DDT00:00:00+00:00')."""
state_dir = tmp_path / "theses"
input_file = _write_json(
tmp_path,
{
"ticker": "NVDA",
"thesis_statement": "NVDA",
"thesis_type": "growth_momentum",
"entry_date": entry_date,
},
)
ids = thesis_ingest.ingest("manual", input_file, str(state_dir))
assert len(ids) == 1
thesis = thesis_store.get(state_dir, ids[0])
assert thesis["status_history"][0]["at"] == "2026-05-02T00:00:00+00:00"
assert "_20260502_" in ids[0]
def test_manual_backdated_lifecycle_monotonic_e2e(tmp_path: Path):
"""The issue's repro: a pre-existing fractional broker position reaches
ACTIVE via manual ingest → transition --event-date → open-position
--event-date, and the fully backdated status_history saves cleanly
(IDEA == ENTRY_READY == ACTIVE == entry date)."""
state_dir = tmp_path / "theses"
input_file = _write_json(
tmp_path,
{
"ticker": "AMD",
"thesis_statement": "AMD fractional position from IBI Smart",
"thesis_type": "growth_momentum",
"entry_price": 142.10,
"entry_date": "2026-05-02",
"shares": 7.86,
},
)
ids = thesis_ingest.ingest("manual", input_file, str(state_dir))
tid = ids[0]
sd = str(state_dir)
assert (
thesis_store.main(
[
"--state-dir",
sd,
"transition",
tid,
"ENTRY_READY",
"--reason",
"existing IBI Smart position",
"--event-date",
"2026-05-02",
]
)
== 0
)
assert (
thesis_store.main(
[
"--state-dir",
sd,
"open-position",
tid,
"--actual-price",
"142.10",
"--actual-date",
"2026-05-02",
"--shares",
"7.86",
"--event-date",
"2026-05-02",
]
)
== 0
)
t = thesis_store.get(state_dir, tid) # get() implies it saved + validated
assert t["status"] == "ACTIVE"
assert t["position"]["shares"] == 7.86
ats = [h["at"] for h in t["status_history"]]
assert ats == ["2026-05-02T00:00:00+00:00"] * 3
"""Tests for thesis_review.py — review, postmortem, and MAE/MFE."""
import json
from pathlib import Path
import pytest
import thesis_review
import thesis_store
# -- Helpers -------------------------------------------------------------------
def _make_thesis_data(**overrides):
data = {
"ticker": "AAPL",
"thesis_type": "dividend_income",
"thesis_statement": "AAPL dividend test thesis",
"origin": {"skill": "test", "output_file": "test.json"},
}
data.update(overrides)
return data
_ACTIVE_COUNTER = 0
def _create_active_thesis(
state_dir: Path, entry_price=150.0, entry_date="2026-03-01T10:00:00+00:00"
):
"""Create a thesis in ACTIVE state with entry data."""
global _ACTIVE_COUNTER
_ACTIVE_COUNTER += 1
data = _make_thesis_data(thesis_statement=f"test thesis #{_ACTIVE_COUNTER}")
tid = thesis_store.register(state_dir, data)
thesis_store.transition(state_dir, tid, "ENTRY_READY", "ok")
thesis_store.open_position(state_dir, tid, entry_price, entry_date)
return tid
def _create_closed_thesis(state_dir: Path, entry_price=150.0, exit_price=165.0, pnl_pct=10.0):
"""Create a thesis in CLOSED state."""
tid = _create_active_thesis(state_dir, entry_price)
thesis_store.close(state_dir, tid, "target_hit", exit_price, "2026-04-01T10:00:00+00:00")
return tid
class MockPriceAdapter:
"""Mock adapter returning fixed prices."""
def __init__(self, prices):
self.prices = prices
def get_daily_closes(self, ticker, from_date, to_date):
return self.prices
# -- Tests: list_review_due ---------------------------------------------------
def test_list_review_due_filters_correctly(tmp_path: Path):
"""list_review_due: in-range returned, out-of-range excluded."""
state_dir = tmp_path / "theses"
tid1 = thesis_store.register(state_dir, _make_thesis_data(ticker="AAPL"))
tid2 = thesis_store.register(state_dir, _make_thesis_data(ticker="MSFT"))
# Set tid1 as due, tid2 as not due
thesis_store.update(state_dir, tid1, {"monitoring": {"next_review_date": "2026-03-01"}})
thesis_store.update(state_dir, tid2, {"monitoring": {"next_review_date": "2026-06-01"}})
due = thesis_store.list_review_due(state_dir, "2026-03-14")
tids = [d["thesis_id"] for d in due]
assert tid1 in tids
assert tid2 not in tids
# -- Tests: compute_mae_mfe ---------------------------------------------------
def test_compute_mae_mfe_with_mock_adapter(tmp_path: Path):
"""compute_mae_mfe: mock adapter → correct MAE/MFE values."""
state_dir = tmp_path / "theses"
tid = _create_closed_thesis(state_dir, entry_price=150.0, exit_price=165.0)
thesis = thesis_store.get(state_dir, tid)
adapter = MockPriceAdapter(
[
{"date": "2026-03-01", "close": 150.0},
{"date": "2026-03-05", "close": 145.0}, # MAE: -3.33%
{"date": "2026-03-15", "close": 170.0}, # MFE: +13.33%
{"date": "2026-04-01", "close": 165.0},
]
)
result = thesis_review.compute_mae_mfe(thesis, adapter)
assert result["mae_pct"] == pytest.approx(-3.33, abs=0.01)
assert result["mfe_pct"] == pytest.approx(13.33, abs=0.01)
assert result["mae_mfe_source"] == "fmp_eod"
def test_compute_mae_mfe_no_adapter_returns_nulls(tmp_path: Path):
"""compute_mae_mfe: adapter=None → null values, no error."""
state_dir = tmp_path / "theses"
tid = _create_closed_thesis(state_dir)
thesis = thesis_store.get(state_dir, tid)
result = thesis_review.compute_mae_mfe(thesis, None)
assert result["mae_pct"] is None
assert result["mfe_pct"] is None
assert result["mae_mfe_source"] is None
# -- Tests: generate_postmortem ------------------------------------------------
def test_generate_postmortem_rejects_active(tmp_path: Path):
"""generate_postmortem should reject non-CLOSED/INVALIDATED theses."""
state_dir = tmp_path / "theses"
tid = _create_active_thesis(state_dir)
with pytest.raises(ValueError, match="CLOSED or INVALIDATED"):
thesis_review.generate_postmortem(tid, str(state_dir))
def test_generate_postmortem_allows_invalidated(tmp_path: Path):
"""generate_postmortem should work for INVALIDATED theses."""
state_dir = tmp_path / "theses"
journal_dir = tmp_path / "journal"
tid = thesis_store.register(state_dir, _make_thesis_data())
thesis_store.terminate(state_dir, tid, "INVALIDATED", "kill criteria")
pm_path = thesis_review.generate_postmortem(tid, str(state_dir), journal_dir=str(journal_dir))
content = Path(pm_path).read_text()
assert "INVALIDATED" in content
def test_generate_postmortem_contains_pnl(tmp_path: Path):
"""generate_postmortem: output contains pnl and holding_days."""
state_dir = tmp_path / "theses"
journal_dir = tmp_path / "journal"
tid = _create_closed_thesis(state_dir, entry_price=150.0, exit_price=165.0)
pm_path = thesis_review.generate_postmortem(tid, str(state_dir), journal_dir=str(journal_dir))
content = Path(pm_path).read_text()
assert "Postmortem:" in content
assert "AAPL" in content
assert "10.0%" in content # pnl_pct
assert "target_hit" in content
assert "31" in content # holding_days
def test_generate_postmortem_with_adapter(tmp_path: Path):
"""generate_postmortem: with price adapter updates MAE/MFE."""
state_dir = tmp_path / "theses"
journal_dir = tmp_path / "journal"
tid = _create_closed_thesis(state_dir, entry_price=150.0, exit_price=165.0)
adapter = MockPriceAdapter(
[
{"date": "2026-03-01", "close": 150.0},
{"date": "2026-03-10", "close": 140.0},
{"date": "2026-03-20", "close": 172.0},
]
)
thesis_review.generate_postmortem(
tid, str(state_dir), price_adapter=adapter, journal_dir=str(journal_dir)
)
# Verify thesis was updated
thesis = thesis_store.get(state_dir, tid)
assert thesis["outcome"]["mae_pct"] is not None
assert thesis["outcome"]["mfe_pct"] is not None
# -- Tests: summary_stats -----------------------------------------------------
def test_summary_stats_three_theses(tmp_path: Path):
"""summary_stats: 3 closed theses → correct win rate."""
state_dir = tmp_path / "theses"
# Win (10%)
_create_closed_thesis(state_dir, entry_price=100.0, exit_price=110.0)
# Win (5%)
_create_closed_thesis(state_dir, entry_price=100.0, exit_price=105.0)
# Loss (-10%)
tid3 = _create_active_thesis(state_dir, entry_price=100.0)
thesis_store.close(state_dir, tid3, "stop_hit", 90.0, "2026-04-01T10:00:00+00:00")
stats = thesis_review.summary_stats(str(state_dir))
assert stats["count"] == 3
assert stats["win_rate"] == pytest.approx(0.6667, abs=0.001)
assert stats["avg_pnl_pct"] == pytest.approx(1.67, abs=0.01)
def test_summary_stats_includes_invalidated_with_pnl(tmp_path: Path):
"""summary_stats should include INVALIDATED theses that have P&L."""
state_dir = tmp_path / "theses"
# 1 closed win (+10%)
_create_closed_thesis(state_dir, entry_price=100.0, exit_price=110.0)
# 1 invalidated with P&L (-5%)
tid2 = _create_active_thesis(state_dir, entry_price=100.0)
thesis_store.terminate(
state_dir,
tid2,
"INVALIDATED",
"kill criteria",
actual_price=95.0,
actual_date="2026-04-01T10:00:00+00:00",
)
# 1 invalidated without P&L (IDEA → INVALIDATED, no position)
data = _make_thesis_data(thesis_statement="no position thesis")
tid3 = thesis_store.register(state_dir, data)
thesis_store.terminate(state_dir, tid3, "INVALIDATED", "not interested")
stats = thesis_review.summary_stats(str(state_dir))
assert stats["count"] == 2 # only those with P&L
assert stats["win_rate"] == 0.5 # 1 win, 1 loss
assert stats["avg_pnl_pct"] == pytest.approx(2.5, abs=0.01) # (10 + -5) / 2
def test_summary_entries_filters_and_groups(tmp_path: Path):
"""Filtered summary should reuse index-backed query fields."""
state_dir = tmp_path / "theses"
thesis_store.register(
state_dir,
_make_thesis_data(
ticker="AAPL",
thesis_type="dividend_income",
thesis_statement="old AAPL thesis",
_source_date="2026-03-01",
),
)
tid = thesis_store.register(
state_dir,
_make_thesis_data(
ticker="MSFT",
thesis_type="growth_momentum",
thesis_statement="new MSFT thesis",
_source_date="2026-05-01",
),
)
thesis_store.transition(state_dir, tid, "ENTRY_READY", "ready")
summary = thesis_review.summary_entries(
str(state_dir),
status="ENTRY_READY",
since="2026-04-01",
by="thesis_type",
)
assert summary["count"] == 1
assert summary["entries"][0]["ticker"] == "MSFT"
assert summary["groups"] == {"growth_momentum": 1}
def test_summary_entries_as_of_filters_not_yet_due_active(tmp_path: Path):
"""--as-of should act as a review-due snapshot for non-terminal theses."""
state_dir = tmp_path / "theses"
due = thesis_store.register(
state_dir,
_make_thesis_data(ticker="DUE", thesis_statement="due thesis"),
)
later = thesis_store.register(
state_dir,
_make_thesis_data(ticker="LATE", thesis_statement="later thesis"),
)
thesis_store.update(state_dir, due, {"monitoring": {"next_review_date": "2026-04-01"}})
thesis_store.update(state_dir, later, {"monitoring": {"next_review_date": "2026-06-01"}})
summary = thesis_review.summary_entries(str(state_dir), as_of="2026-05-01")
assert summary["count"] == 1
assert summary["entries"][0]["ticker"] == "DUE"
def test_format_compact_summary_one_line_per_thesis(tmp_path: Path):
state_dir = tmp_path / "theses"
thesis_store.register(state_dir, _make_thesis_data(ticker="AAPL"))
summary = thesis_review.summary_entries(str(state_dir), ticker="AAPL")
compact = thesis_review.format_compact_summary(summary)
assert "AAPL" in compact
assert "IDEA" in compact
assert compact.count("\n") == 0
def test_main_summary_preserves_default_json(tmp_path: Path, capsys):
state_dir = tmp_path / "theses"
_create_closed_thesis(state_dir, entry_price=100.0, exit_price=110.0)
assert thesis_review.main(["--state-dir", str(state_dir), "summary"]) == 0
out = capsys.readouterr().out
data = json.loads(out)
assert data["count"] == 1
assert "by_type" in data
def test_monthly_report_uses_exit_date_not_created_at(tmp_path: Path):
"""Monthly report membership should use exit/status-history dates."""
state_dir = tmp_path / "theses"
journal_dir = tmp_path / "journal"
tid_apr = _create_active_thesis(state_dir, entry_price=100.0)
thesis_store.close(state_dir, tid_apr, "target_hit", 110.0, "2026-04-15T10:00:00+00:00")
thesis_store.update(
state_dir,
tid_apr,
{"outcome": {"lessons_learned": "Let winners work"}},
)
tid_may = _create_active_thesis(state_dir, entry_price=100.0)
thesis_store.close(state_dir, tid_may, "stop_hit", 90.0, "2026-05-02T10:00:00+00:00")
report_path = thesis_review.monthly_report(
str(state_dir),
"2026-04",
journal_dir=str(journal_dir),
)
assert report_path == str(journal_dir / "monthly-review-2026-04.md")
content = Path(report_path).read_text()
assert "# Monthly Review: 2026-04" in content
assert "Closed/invalidated theses: 1" in content
assert "target_hit: 1" in content
assert "Let winners work" in content
assert "stop_hit" not in content
def test_main_monthly_report_output_override(tmp_path: Path, capsys):
state_dir = tmp_path / "theses"
out_path = tmp_path / "custom.md"
_create_closed_thesis(state_dir, entry_price=100.0, exit_price=110.0)
assert (
thesis_review.main(
[
"--state-dir",
str(state_dir),
"monthly-report",
"--month",
"2026-04",
"--output",
str(out_path),
]
)
== 0
)
assert out_path.exists()
assert "Monthly report generated" in capsys.readouterr().out
"""Tests for thesis_store.py — CRUD, transitions, and index management."""
import json
from pathlib import Path
import pytest
import thesis_store
# -- Helpers -------------------------------------------------------------------
def _make_thesis_data(**overrides):
"""Create minimal thesis data for registration."""
data = {
"ticker": "AAPL",
"thesis_type": "dividend_income",
"thesis_statement": "AAPL dividend income thesis for testing",
"origin": {
"skill": "test-skill",
"output_file": "test_output.json",
},
}
data.update(overrides)
return data
def _register_and_get(state_dir, **overrides):
"""Register a thesis and return (thesis_id, thesis_dict)."""
data = _make_thesis_data(**overrides)
tid = thesis_store.register(state_dir, data)
thesis = thesis_store.get(state_dir, tid)
return tid, thesis
# -- Tests: register + get ----------------------------------------------------
def test_register_and_get_match(tmp_path: Path):
"""register → get should return matching thesis."""
tid, thesis = _register_and_get(tmp_path)
assert thesis["thesis_id"] == tid
assert thesis["ticker"] == "AAPL"
assert thesis["thesis_type"] == "dividend_income"
assert thesis["status"] == "IDEA"
assert len(thesis["status_history"]) == 1
assert thesis["status_history"][0]["status"] == "IDEA"
assert thesis["created_at"] is not None
assert thesis["updated_at"] is not None
def test_thesis_id_contains_hash4(tmp_path: Path):
"""thesis_id should contain a 4-char hex hash suffix."""
tid, _ = _register_and_get(tmp_path)
parts = tid.split("_")
assert len(parts) == 5 # th, ticker, abbr, date, hash4
assert parts[0] == "th"
assert parts[1] == "aapl"
assert parts[2] == "div"
assert len(parts[3]) == 8 # YYYYMMDD
assert len(parts[4]) == 4 # hash4
def test_same_input_idempotent(tmp_path: Path):
"""Same input data should return the same thesis_id (idempotent)."""
tid1 = thesis_store.register(tmp_path, _make_thesis_data())
tid2 = thesis_store.register(tmp_path, _make_thesis_data())
assert tid1 == tid2
def test_different_content_different_ids(tmp_path: Path):
"""Different thesis content should produce different IDs."""
tid1 = thesis_store.register(
tmp_path,
_make_thesis_data(
thesis_statement="thesis A",
),
)
tid2 = thesis_store.register(
tmp_path,
_make_thesis_data(
thesis_statement="thesis B",
),
)
assert tid1 != tid2
def test_register_missing_required_field(tmp_path: Path):
"""Missing required field should raise ValueError."""
with pytest.raises(ValueError, match="Missing required field"):
thesis_store.register(tmp_path, {"ticker": "AAPL", "thesis_type": "dividend_income"})
def test_register_invalid_thesis_type(tmp_path: Path):
"""Invalid thesis_type should raise ValueError."""
with pytest.raises(ValueError, match="Invalid thesis_type"):
thesis_store.register(tmp_path, _make_thesis_data(thesis_type="unknown_type"))
def test_find_by_fingerprint_yaml_fallback(tmp_path: Path):
"""When index is empty, fingerprint lookup should fall back to YAML scan."""
tid = thesis_store.register(tmp_path, _make_thesis_data())
# Remove index to simulate empty/corrupt
index_path = tmp_path / thesis_store.INDEX_FILE
index_path.write_text('{"version": 1, "theses": {}}')
# Should still find via YAML fallback
thesis = thesis_store.get(tmp_path, tid)
fp = thesis.get("origin_fingerprint")
found = thesis_store._find_by_fingerprint(tmp_path, fp)
assert found == tid
def test_register_updates_index(tmp_path: Path):
"""Registration should update _index.json."""
tid = thesis_store.register(tmp_path, _make_thesis_data())
index = thesis_store._load_index(tmp_path)
assert tid in index["theses"]
assert index["theses"][tid]["ticker"] == "AAPL"
assert index["theses"][tid]["status"] == "IDEA"
def test_register_sets_next_review_date(tmp_path: Path):
"""Registration should set next_review_date based on interval."""
tid, thesis = _register_and_get(tmp_path)
assert thesis["monitoring"]["next_review_date"] is not None
# -- Tests: transition ---------------------------------------------------------
def test_transition_forward_path(tmp_path: Path):
"""IDEA → ENTRY_READY → ACTIVE (via open_position) should log history."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "validated")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-14T10:00:00+00:00")
thesis = thesis_store.get(tmp_path, tid)
assert thesis["status"] == "ACTIVE"
assert len(thesis["status_history"]) == 3
assert thesis["status_history"][0]["status"] == "IDEA"
assert thesis["status_history"][1]["status"] == "ENTRY_READY"
assert thesis["status_history"][2]["status"] == "ACTIVE"
def test_transition_backward_raises(tmp_path: Path):
"""ACTIVE → IDEA should raise ValueError."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "validated")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-14T10:00:00+00:00")
with pytest.raises(ValueError, match="Cannot transition backward"):
thesis_store.transition(tmp_path, tid, "IDEA", "oops")
def test_transition_to_active_raises(tmp_path: Path):
"""transition() to ACTIVE should raise, forcing use of open_position()."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
with pytest.raises(ValueError, match="Use open_position"):
thesis_store.transition(tmp_path, tid, "ACTIVE", "bad")
def test_terminate_any_to_invalidated(tmp_path: Path):
"""Any non-terminal status should allow → INVALIDATED via terminate()."""
tid, _ = _register_and_get(tmp_path)
thesis_store.terminate(tmp_path, tid, "INVALIDATED", "kill criteria triggered")
thesis = thesis_store.get(tmp_path, tid)
assert thesis["status"] == "INVALIDATED"
def test_transition_from_terminal_raises(tmp_path: Path):
"""Cannot transition from INVALIDATED."""
tid, _ = _register_and_get(tmp_path)
thesis_store.terminate(tmp_path, tid, "INVALIDATED", "killed")
with pytest.raises(ValueError, match="Cannot transition from terminal"):
thesis_store.transition(tmp_path, tid, "IDEA", "oops")
# -- Tests: open_position ------------------------------------------------------
def test_open_position_sets_entry_and_activates(tmp_path: Path):
"""open_position should set entry data and transition to ACTIVE."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis = thesis_store.open_position(
tmp_path, tid, 155.0, "2026-03-20T10:00:00+00:00", shares=100
)
assert thesis["status"] == "ACTIVE"
assert thesis["entry"]["actual_price"] == 155.0
assert thesis["entry"]["actual_date"] == "2026-03-20T10:00:00+00:00"
assert thesis["position"]["shares"] == 100
def test_open_position_from_idea_raises(tmp_path: Path):
"""open_position from IDEA (not ENTRY_READY) should raise."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="requires ENTRY_READY"):
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-20T10:00:00+00:00")
# -- Tests: terminate ---------------------------------------------------------
def test_terminate_active_invalidated_with_price(tmp_path: Path):
"""terminate ACTIVE→INVALIDATED with price should compute P&L."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00")
thesis = thesis_store.terminate(
tmp_path,
tid,
"INVALIDATED",
"kill criteria",
actual_price=140.0,
actual_date="2026-03-10T10:00:00+00:00",
)
assert thesis["status"] == "INVALIDATED"
assert thesis["outcome"]["pnl_pct"] == pytest.approx(-6.67, abs=0.01)
assert thesis["outcome"]["holding_days"] == 9
def test_terminate_active_invalidated_no_price(tmp_path: Path):
"""terminate ACTIVE→INVALIDATED without price should leave P&L null."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00")
thesis = thesis_store.terminate(tmp_path, tid, "INVALIDATED", "kill criteria")
assert thesis["status"] == "INVALIDATED"
assert thesis["outcome"]["pnl_pct"] is None
def test_terminate_idea_invalidated(tmp_path: Path):
"""terminate IDEA→INVALIDATED (no position) should work."""
tid, _ = _register_and_get(tmp_path)
thesis = thesis_store.terminate(tmp_path, tid, "INVALIDATED", "not interested")
assert thesis["status"] == "INVALIDATED"
def test_terminate_closed_delegates(tmp_path: Path):
"""terminate with CLOSED should delegate to close()."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00")
thesis = thesis_store.terminate(
tmp_path,
tid,
"CLOSED",
"target_hit",
actual_price=165.0,
actual_date="2026-04-01T10:00:00+00:00",
)
assert thesis["status"] == "CLOSED"
assert thesis["outcome"]["pnl_pct"] == 10.0
# -- Tests: attach_position ----------------------------------------------------
def _make_position_report(tmp_path: Path, **overrides):
"""Create a mock position-sizer JSON report."""
report = {
"schema_version": "1.0",
"mode": "shares",
"parameters": {
"entry_price": 150.00,
"stop_price": 142.00,
"account_size": 100000,
"risk_pct": 1.0,
},
"calculations": {
"fixed_fractional": {"method": "fixed_fractional", "shares": 125},
},
"final_recommended_shares": 125,
"final_position_value": 18750.00,
"final_risk_dollars": 1000.00,
"final_risk_pct": 0.01,
}
report.update(overrides)
report_path = tmp_path / "position_report.json"
report_path.write_text(json.dumps(report))
return str(report_path)
def test_attach_position_populates_section(tmp_path: Path):
"""attach_position should populate thesis.position with raw_source."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
report_path = _make_position_report(tmp_path)
thesis = thesis_store.attach_position(state_dir, tid, report_path)
assert thesis["position"] is not None
assert thesis["position"]["shares"] == 125
assert thesis["position"]["position_value"] == 18750.00
assert thesis["position"]["risk_dollars"] == 1000.00
assert thesis["position"]["raw_source"]["skill"] == "position-sizer"
assert thesis["position"]["raw_source"]["fields"]["final_recommended_shares"] == 125
def test_attach_position_mismatched_entry_raises(tmp_path: Path):
"""attach_position with wrong expected_entry should raise ValueError."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
report_path = _make_position_report(tmp_path)
with pytest.raises(ValueError, match="Entry price mismatch"):
thesis_store.attach_position(state_dir, tid, report_path, expected_entry=999.99)
def test_attach_position_budget_mode_raises(tmp_path: Path):
"""attach_position with budget mode report should raise ValueError."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
report_path = _make_position_report(tmp_path, mode="budget")
with pytest.raises(ValueError, match="mode is 'budget'"):
thesis_store.attach_position(state_dir, tid, report_path)
# -- Tests: close --------------------------------------------------------------
def test_attach_position_atr_based_method(tmp_path: Path):
"""attach_position should detect atr_based sizing method."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
report = {
"schema_version": "1.0",
"mode": "shares",
"parameters": {"entry_price": 150.00, "stop_price": 142.00},
"calculations": {
"fixed_fractional": None,
"atr_based": {"method": "atr_based", "shares": 100, "stop_price": 142.00},
"kelly": None,
},
"final_recommended_shares": 100,
"final_position_value": 15000.00,
"final_risk_dollars": 800.00,
"final_risk_pct": 0.008,
}
report_path = tmp_path / "atr_report.json"
report_path.write_text(json.dumps(report))
thesis = thesis_store.attach_position(state_dir, tid, str(report_path))
assert thesis["position"]["sizing_method"] == "atr_based"
def test_attach_position_kelly_method(tmp_path: Path):
"""attach_position should detect kelly sizing method."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
report = {
"schema_version": "1.0",
"mode": "shares",
"parameters": {"entry_price": 150.00, "stop_price": 142.00},
"calculations": {
"fixed_fractional": None,
"atr_based": None,
"kelly": {"method": "kelly", "kelly_pct": 10.0, "half_kelly_pct": 5.0},
},
"final_recommended_shares": 80,
"final_position_value": 12000.00,
"final_risk_dollars": 640.00,
"final_risk_pct": 0.0064,
}
report_path = tmp_path / "kelly_report.json"
report_path.write_text(json.dumps(report))
thesis = thesis_store.attach_position(state_dir, tid, str(report_path))
assert thesis["position"]["sizing_method"] == "kelly"
def test_close_computes_pnl_and_holding_days(tmp_path: Path):
"""close() should compute pnl_dollars, pnl_pct, and holding_days."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
# Advance to ACTIVE via open_position
thesis_store.transition(state_dir, tid, "ENTRY_READY", "validated")
thesis_store.open_position(state_dir, tid, 150.00, "2026-03-01T10:00:00+00:00")
# Attach position for pnl_dollars calculation
report_path = _make_position_report(tmp_path)
thesis_store.attach_position(state_dir, tid, report_path)
# Close
thesis = thesis_store.close(
state_dir,
tid,
exit_reason="target_hit",
actual_price=165.00,
actual_date="2026-04-01T10:00:00+00:00",
)
assert thesis["status"] == "CLOSED"
assert thesis["outcome"]["pnl_pct"] == 10.0 # (165-150)/150 * 100
assert thesis["outcome"]["pnl_dollars"] == 1875.0 # 15 * 125 shares
assert thesis["outcome"]["holding_days"] == 31
assert thesis["exit"]["exit_reason"] == "target_hit"
def test_close_non_active_raises(tmp_path: Path):
"""close() on non-ACTIVE thesis should raise ValueError."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
with pytest.raises(ValueError, match="Can only close ACTIVE"):
thesis_store.close(state_dir, tid, "manual", 160.0, "2026-04-01T00:00:00+00:00")
# -- Tests: schema validation --------------------------------------------------
def test_close_with_invalid_exit_reason_fails(tmp_path: Path):
"""close() with invalid exit_reason should fail validation."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
thesis_store.transition(state_dir, tid, "ENTRY_READY", "ok")
thesis_store.open_position(state_dir, tid, 150.0, "2026-03-01T10:00:00+00:00")
with pytest.raises(ValueError):
thesis_store.close(state_dir, tid, "banana", 160.0, "2026-04-01T00:00:00+00:00")
def test_register_without_origin_fails(tmp_path: Path):
"""Registering without origin should fail early validation."""
data = {
"ticker": "AAPL",
"thesis_type": "dividend_income",
"thesis_statement": "test thesis",
# no origin
}
# register() validates origin sub-fields before fingerprint check
with pytest.raises(ValueError, match="origin.skill"):
thesis_store.register(tmp_path, data)
def test_exit_date_before_entry_date_fails(tmp_path: Path):
"""close() with exit_date < entry_date should fail validation."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
thesis_store.transition(state_dir, tid, "ENTRY_READY", "ok")
thesis_store.open_position(state_dir, tid, 150.0, "2026-04-01T10:00:00+00:00")
with pytest.raises(ValueError, match="exit.actual_date must be >= entry.actual_date"):
thesis_store.close(state_dir, tid, "manual", 155.0, "2026-03-01T10:00:00+00:00")
# -- Tests: source date --------------------------------------------------------
def test_register_with_source_date(tmp_path: Path):
"""_source_date should set thesis_id, created_at, status_history, next_review from source."""
data = _make_thesis_data(_source_date="2026-02-20")
tid = thesis_store.register(tmp_path, data)
thesis = thesis_store.get(tmp_path, tid)
# thesis_id should contain 20260220, not today
assert "_20260220_" in tid
# created_at should reflect source date
assert thesis["created_at"].startswith("2026-02-20")
# updated_at should be now (not source date)
assert not thesis["updated_at"].startswith("2026-02-20")
# status_history[0].at should use source date, not now
assert thesis["status_history"][0]["at"].startswith("2026-02-20")
# next_review_date should be source_date + 30 days = 2026-03-22
assert thesis["monitoring"]["next_review_date"] == "2026-03-22"
def test_register_without_source_date_uses_today(tmp_path: Path):
"""Without _source_date, register uses today's date."""
data = _make_thesis_data()
tid = thesis_store.register(tmp_path, data)
# Should not contain a past date
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime("%Y%m%d")
assert f"_{today}_" in tid
# -- Tests: query and list -----------------------------------------------------
def test_query_by_date_range(tmp_path: Path):
"""query(date_from=, date_to=) should filter by created_at."""
thesis_store.register(tmp_path, _make_thesis_data(ticker="OLD", _source_date="2026-01-15"))
thesis_store.register(tmp_path, _make_thesis_data(ticker="MID", _source_date="2026-02-15"))
thesis_store.register(tmp_path, _make_thesis_data(ticker="NEW", _source_date="2026-03-15"))
# Only MID
results = thesis_store.query(tmp_path, date_from="2026-02-01", date_to="2026-02-28")
tickers = [r["ticker"] for r in results]
assert "MID" in tickers
assert "OLD" not in tickers
assert "NEW" not in tickers
# MID + NEW
results = thesis_store.query(tmp_path, date_from="2026-02-01")
tickers = [r["ticker"] for r in results]
assert "MID" in tickers
assert "NEW" in tickers
assert "OLD" not in tickers
def test_query_by_ticker(tmp_path: Path):
"""query(ticker=) should filter correctly."""
thesis_store.register(tmp_path, _make_thesis_data(ticker="AAPL"))
thesis_store.register(tmp_path, _make_thesis_data(ticker="MSFT"))
results = thesis_store.query(tmp_path, ticker="AAPL")
assert len(results) == 1
assert results[0]["ticker"] == "AAPL"
def test_list_review_due(tmp_path: Path):
"""list_review_due should return theses with due dates."""
tid = thesis_store.register(tmp_path, _make_thesis_data())
# Override next_review_date to past
thesis_store.update(
tmp_path,
tid,
{
"monitoring": {"next_review_date": "2026-01-01"},
},
)
due = thesis_store.list_review_due(tmp_path, "2026-03-14")
assert len(due) == 1
assert due[0]["thesis_id"] == tid
not_due = thesis_store.list_review_due(tmp_path, "2025-12-31")
assert len(not_due) == 0
def test_list_active(tmp_path: Path):
"""list_active should return only ACTIVE theses."""
tid1 = thesis_store.register(tmp_path, _make_thesis_data(ticker="AAPL"))
thesis_store.register(tmp_path, _make_thesis_data(ticker="MSFT"))
thesis_store.transition(tmp_path, tid1, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid1, 150.0, "2026-03-14T10:00:00+00:00")
active = thesis_store.list_active(tmp_path)
assert len(active) == 1
assert active[0]["ticker"] == "AAPL"
# -- Tests: mark_reviewed ------------------------------------------------------
def test_mark_reviewed_updates_dates(tmp_path: Path):
"""mark_reviewed should update last/next review dates."""
tid, _ = _register_and_get(tmp_path)
thesis = thesis_store.mark_reviewed(tmp_path, tid, review_date="2026-04-01", outcome="OK")
assert thesis["monitoring"]["last_review_date"] == "2026-04-01"
assert thesis["monitoring"]["next_review_date"] == "2026-05-01"
assert thesis["monitoring"]["review_status"] == "OK"
def test_mark_reviewed_escalation(tmp_path: Path):
"""mark_reviewed with WARN outcome should set review_status."""
tid, _ = _register_and_get(tmp_path)
thesis = thesis_store.mark_reviewed(tmp_path, tid, review_date="2026-04-01", outcome="WARN")
assert thesis["monitoring"]["review_status"] == "WARN"
def test_mark_reviewed_notes_to_alerts(tmp_path: Path):
"""mark_reviewed with notes should append to alerts."""
tid, _ = _register_and_get(tmp_path)
thesis = thesis_store.mark_reviewed(
tmp_path,
tid,
review_date="2026-04-01",
outcome="REVIEW",
notes="FCF coverage dropped below 1.5x",
)
assert len(thesis["monitoring"]["alerts"]) == 1
assert (
"[2026-04-01] REVIEW: FCF coverage dropped below 1.5x" in thesis["monitoring"]["alerts"][0]
)
def test_mark_reviewed_terminal_raises(tmp_path: Path):
"""mark_reviewed on CLOSED thesis should raise ValueError."""
tid, _ = _register_and_get(tmp_path)
thesis_store.terminate(tmp_path, tid, "INVALIDATED", "killed")
with pytest.raises(ValueError, match="Cannot review terminal"):
thesis_store.mark_reviewed(tmp_path, tid, review_date="2026-04-01")
def test_mark_reviewed_next_based_on_review_date(tmp_path: Path):
"""next_review should be review_date + interval, not now + interval."""
tid, _ = _register_and_get(tmp_path)
thesis = thesis_store.mark_reviewed(tmp_path, tid, review_date="2026-01-15")
# 2026-01-15 + 30 = 2026-02-14
assert thesis["monitoring"]["next_review_date"] == "2026-02-14"
# -- Tests: rebuild_index / validate_state ------------------------------------
def test_rebuild_index_from_scratch(tmp_path: Path):
"""rebuild_index should recreate index from YAML files."""
tid = thesis_store.register(tmp_path, _make_thesis_data())
# Delete index
(tmp_path / thesis_store.INDEX_FILE).unlink()
# Rebuild
idx = thesis_store.rebuild_index(tmp_path)
assert tid in idx["theses"]
assert idx["theses"][tid]["ticker"] == "AAPL"
def test_rebuild_index_skips_corrupt(tmp_path: Path):
"""rebuild_index should skip corrupt YAML files."""
thesis_store.register(tmp_path, _make_thesis_data())
# Create corrupt file
(tmp_path / "th_bad_pvt_20260314_0000.yaml").write_text("{{invalid yaml")
idx = thesis_store.rebuild_index(tmp_path)
assert len(idx["theses"]) == 1 # only the valid one
def test_rebuild_index_skips_schema_invalid(tmp_path: Path):
"""rebuild_index should skip YAML files that fail schema validation."""
import yaml
tid = thesis_store.register(tmp_path, _make_thesis_data())
thesis = thesis_store.get(tmp_path, tid)
# Create a schema-invalid thesis YAML (bogus status)
bad = dict(thesis)
bad["thesis_id"] = "th_bad_div_20260314_0000"
bad["status"] = "BOGUS"
bad_path = tmp_path / "th_bad_div_20260314_0000.yaml"
bad_path.write_text(yaml.dump(bad, default_flow_style=False))
idx = thesis_store.rebuild_index(tmp_path)
assert tid in idx["theses"]
assert "th_bad_div_20260314_0000" not in idx["theses"]
def test_validate_state_detects_missing(tmp_path: Path):
"""validate_state should detect files missing from index."""
tid = thesis_store.register(tmp_path, _make_thesis_data())
# Remove from index but keep YAML
index = thesis_store._load_index(tmp_path)
del index["theses"][tid]
thesis_store._save_index(tmp_path, index)
result = thesis_store.validate_state(tmp_path)
assert not result["ok"]
assert tid in result["missing_in_index"]
def test_validate_state_detects_orphan(tmp_path: Path):
"""validate_state should detect index entries without YAML files."""
tid = thesis_store.register(tmp_path, _make_thesis_data())
# Remove YAML but keep index entry
(tmp_path / f"{tid}.yaml").unlink()
result = thesis_store.validate_state(tmp_path)
assert not result["ok"]
assert tid in result["orphaned_in_index"]
# -- Tests: link_report -------------------------------------------------------
def test_link_report(tmp_path: Path):
"""link_report should append to linked_reports."""
tid, _ = _register_and_get(tmp_path)
thesis = thesis_store.link_report(
tmp_path,
tid,
skill="us-stock-analysis",
file="reports/aapl_analysis.md",
date="2026-03-14",
)
assert len(thesis["linked_reports"]) == 1
assert thesis["linked_reports"][0]["skill"] == "us-stock-analysis"
# -- Tests: FormatChecker (Step 1) -------------------------------------------
def test_open_position_bad_date_format_fails(tmp_path: Path):
"""open_position with invalid date format should fail validation."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
with pytest.raises(ValueError):
thesis_store.open_position(tmp_path, tid, 150.0, "not-a-date")
def test_format_checker_rejects_no_timezone(tmp_path: Path):
"""date-time without timezone offset should fail validation."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
with pytest.raises(ValueError):
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-14T09:00:00")
def test_format_checker_rejects_space_separator(tmp_path: Path):
"""date-time with space separator should fail validation."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
with pytest.raises(ValueError):
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-14 09:00:00+00:00")
def test_close_bad_date_format_fails(tmp_path: Path):
"""close() with invalid date format should fail validation."""
state_dir = tmp_path / "theses"
tid, _ = _register_and_get(state_dir)
thesis_store.transition(state_dir, tid, "ENTRY_READY", "ok")
thesis_store.open_position(state_dir, tid, 150.0, "2026-03-01T10:00:00+00:00")
with pytest.raises(ValueError):
thesis_store.close(state_dir, tid, "manual", 160.0, "not-a-date")
# -- Tests: transition terminal block (Step 2) --------------------------------
def test_transition_to_closed_raises(tmp_path: Path):
"""transition() to CLOSED should raise — use close() instead."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-14T10:00:00+00:00")
with pytest.raises(ValueError, match="terminal status"):
thesis_store.transition(tmp_path, tid, "CLOSED", "bad")
def test_transition_to_invalidated_raises(tmp_path: Path):
"""transition() to INVALIDATED should raise — use terminate() instead."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="terminal status"):
thesis_store.transition(tmp_path, tid, "INVALIDATED", "bad")
# -- Tests: INVALIDATED invariant (Step 3) ------------------------------------
def test_terminate_invalidated_exit_before_entry_fails(tmp_path: Path):
"""INVALIDATED with exit_date < entry_date should fail validation."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-10T10:00:00+00:00")
with pytest.raises(ValueError, match="exit.actual_date must be >= entry.actual_date"):
thesis_store.terminate(
tmp_path,
tid,
"INVALIDATED",
"kill criteria",
actual_price=140.0,
actual_date="2026-03-01T10:00:00+00:00", # before entry
)
def test_terminate_invalidated_holding_days_nonnegative(tmp_path: Path):
"""INVALIDATED with valid dates should have non-negative holding_days."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00")
thesis = thesis_store.terminate(
tmp_path,
tid,
"INVALIDATED",
"kill criteria",
actual_price=140.0,
actual_date="2026-03-10T10:00:00+00:00",
)
assert thesis["outcome"]["holding_days"] >= 0
# -- Tests: Fingerprint improvements (Step 4) --------------------------------
def test_fingerprint_ignores_output_file(tmp_path: Path):
"""Different output_file values should produce same thesis (same fingerprint)."""
data1 = _make_thesis_data(origin={"skill": "test-skill", "output_file": "file_v1.json"})
data2 = _make_thesis_data(origin={"skill": "test-skill", "output_file": "file_v2.json"})
tid1 = thesis_store.register(tmp_path, data1)
tid2 = thesis_store.register(tmp_path, data2)
assert tid1 == tid2
def test_register_invalid_input_not_masked_by_idempotency(tmp_path: Path):
"""Invalid input should raise even if fingerprint matches existing thesis."""
thesis_store.register(tmp_path, _make_thesis_data())
# Same content but missing origin.output_file — must not return existing ID
bad_data = _make_thesis_data()
bad_data["origin"] = {"skill": "test-skill"} # missing output_file
with pytest.raises(ValueError, match="origin.output_file"):
thesis_store.register(tmp_path, bad_data)
def test_register_schema_violation_not_masked_by_idempotency(tmp_path: Path):
"""Schema violation should raise even when fingerprint matches existing thesis."""
thesis_store.register(tmp_path, _make_thesis_data())
# Same fingerprint-relevant content, but confidence_score > 1.0 (schema max)
bad_data = _make_thesis_data(confidence_score=999)
with pytest.raises(ValueError, match="validation failed"):
thesis_store.register(tmp_path, bad_data)
def test_fingerprint_fallback_partial_index(tmp_path: Path):
"""YAML scan should prevent duplicates even when index has partial entries."""
data_a = _make_thesis_data(ticker="AAPL")
data_b = _make_thesis_data(ticker="MSFT")
thesis_store.register(tmp_path, data_a)
tid_b = thesis_store.register(tmp_path, data_b)
# Remove tid_b from index but keep YAML
index = thesis_store._load_index(tmp_path)
del index["theses"][tid_b]
thesis_store._save_index(tmp_path, index)
# Re-register same data for B — should find via YAML fallback
tid_b2 = thesis_store.register(tmp_path, data_b)
assert tid_b2 == tid_b
# -- Tests: validate_state schema-aware (Step 5) ------------------------------
def test_validate_state_detects_schema_error(tmp_path: Path):
"""validate_state should report schema-invalid YAML files."""
import yaml
tid = thesis_store.register(tmp_path, _make_thesis_data())
thesis = thesis_store.get(tmp_path, tid)
# Corrupt the thesis: set an invalid status
thesis["status"] = "BOGUS"
yaml_path = tmp_path / f"{tid}.yaml"
yaml_path.write_text(yaml.dump(thesis, default_flow_style=False))
result = thesis_store.validate_state(tmp_path)
assert not result["ok"]
assert len(result["schema_errors"]) == 1
assert result["schema_errors"][0]["thesis_id"] == tid
# -- Tests: Backfill timestamps (Step 6) --------------------------------------
def test_open_position_backfill_event_date(tmp_path: Path):
"""event_date should override status_history.at for backfilling."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
# Use a future date that is after the IDEA/ENTRY_READY timestamps
backfill_date = "2027-06-15T10:00:00+00:00"
thesis = thesis_store.open_position(
tmp_path, tid, 150.0, "2027-06-15T10:00:00+00:00", event_date=backfill_date
)
active_entry = thesis["status_history"][-1]
assert active_entry["status"] == "ACTIVE"
assert active_entry["at"] == backfill_date
# -- Tests: Blocker #1 — Cross-timezone date comparison -----------------------
def test_cross_timezone_exit_after_entry_succeeds(tmp_path: Path):
"""exit in UTC is AFTER entry in JST (real time) — should succeed."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
# entry: 2026-03-01 00:30 JST = 2026-02-28 15:30 UTC
thesis_store.open_position(tmp_path, tid, 100.0, "2026-03-01T00:30:00+09:00")
# exit: 2026-02-28 23:00 UTC — this is AFTER entry in real time
thesis = thesis_store.close(tmp_path, tid, "target_hit", 110.0, "2026-02-28T23:00:00+00:00")
assert thesis["status"] == "CLOSED"
assert thesis["outcome"]["holding_days"] == 0
def test_cross_timezone_exit_before_entry_fails(tmp_path: Path):
"""exit in UTC is BEFORE entry in JST (real time) — should fail."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
# entry: 2026-03-01 00:30 JST = 2026-02-28 15:30 UTC
thesis_store.open_position(tmp_path, tid, 100.0, "2026-03-01T00:30:00+09:00")
# exit: 2026-02-28 10:00 UTC — this is BEFORE entry in real time
with pytest.raises(ValueError, match="exit.actual_date must be >= entry.actual_date"):
thesis_store.close(tmp_path, tid, "stop_hit", 95.0, "2026-02-28T10:00:00+00:00")
# -- Tests: Blocker #2 — Protected identity fields in update() ---------------
def test_update_ticker_rejected(tmp_path: Path):
"""update() must reject ticker changes."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="Cannot update protected field: ticker"):
thesis_store.update(tmp_path, tid, {"ticker": "MSFT"})
def test_update_thesis_type_rejected(tmp_path: Path):
"""update() must reject thesis_type changes."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="Cannot update protected field: thesis_type"):
thesis_store.update(tmp_path, tid, {"thesis_type": "pivot_breakout"})
def test_update_origin_fingerprint_rejected(tmp_path: Path):
"""update() must reject origin_fingerprint changes."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="Cannot update protected field: origin_fingerprint"):
thesis_store.update(tmp_path, tid, {"origin_fingerprint": "hack"})
# -- Tests: Blocker #3 — validate_state full index comparison -----------------
def test_validate_state_detects_review_date_drift(tmp_path: Path):
"""validate_state() must detect next_review_date drift in index."""
tid, _ = _register_and_get(tmp_path)
# Tamper with _index.json next_review_date
index_path = tmp_path / "_index.json"
with open(index_path) as f:
index = json.load(f)
index["theses"][tid]["next_review_date"] = "2099-01-01"
with open(index_path, "w") as f:
json.dump(index, f)
result = thesis_store.validate_state(tmp_path)
assert not result["ok"]
mismatches = [m for m in result["field_mismatches"] if m["field"] == "next_review_date"]
assert len(mismatches) == 1
assert mismatches[0]["index_value"] == "2099-01-01"
# -- Tests: Medium #4 — status_history monotonic ordering ---------------------
def test_event_date_before_previous_history_fails(tmp_path: Path):
"""open_position with event_date before IDEA.at should fail validation."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
# IDEA.at and ENTRY_READY.at are recent (2026-03-16ish)
# Try to open_position with event_date far in the past
with pytest.raises(ValueError, match="status_history.*is before"):
thesis_store.open_position(
tmp_path,
tid,
150.0,
"2020-01-01T10:00:00+00:00",
event_date="2020-01-01T10:00:00+00:00",
)
# -- Tests: Strict date format validation -------------------------------------
def test_update_non_padded_date_rejected(tmp_path: Path):
"""update() must reject non-zero-padded dates like '2026-1-1'."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="not a 'date'|date must be YYYY-MM-DD"):
thesis_store.update(tmp_path, tid, {"monitoring": {"next_review_date": "2026-1-1"}})
def test_link_report_non_padded_date_rejected(tmp_path: Path):
"""link_report() must reject non-zero-padded dates."""
tid, _ = _register_and_get(tmp_path)
with pytest.raises(ValueError, match="not a 'date'|date must be YYYY-MM-DD"):
thesis_store.link_report(tmp_path, tid, "test-skill", "report.md", "2026-1-1")
def test_list_review_due_uses_parsed_date(tmp_path: Path):
"""list_review_due() should use parsed date comparison, not string."""
tid, _ = _register_and_get(tmp_path)
# Verify the thesis shows up as due when as_of is far in the future
results = thesis_store.list_review_due(tmp_path, "2099-12-31")
assert any(r["thesis_id"] == tid for r in results)
# Verify the thesis does NOT show up when as_of is far in the past
results = thesis_store.list_review_due(tmp_path, "2000-01-01")
assert not any(r["thesis_id"] == tid for r in results)
# -- Tests: fractional shares --------------------------------------------------
def test_fractional_shares_end_to_end(tmp_path: Path):
"""open_position with fractional shares → close P&L uses the float qty."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00", shares=7.86)
t = thesis_store.get(tmp_path, tid)
assert t["position"]["shares"] == 7.86
assert isinstance(t["position"]["shares"], float)
thesis_store.close(tmp_path, tid, "target_hit", 165.0, "2026-03-20T10:00:00+00:00")
t = thesis_store.get(tmp_path, tid)
assert t["outcome"]["pnl_dollars"] == round((165.0 - 150.0) * 7.86, 2)
@pytest.mark.parametrize("bad_shares", [0, -1, -0.5])
def test_schema_rejects_nonpositive_shares(tmp_path: Path, bad_shares):
"""exclusiveMinimum:0 — zero and negatives are rejected on save."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
with pytest.raises(ValueError, match="Schema validation failed"):
thesis_store.open_position(
tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00", shares=bad_shares
)
def test_schema_accepts_integer_shares_backward_compat(tmp_path: Path):
"""Existing integer-shares theses stay valid (number ⊇ integer)."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
thesis_store.open_position(tmp_path, tid, 150.0, "2026-03-01T10:00:00+00:00", shares=100)
t = thesis_store.get(tmp_path, tid) # get() implies schema-valid
assert t["position"]["shares"] == 100
# -- Tests: lifecycle CLI (main(argv)) ----------------------------------------
def test_cli_main_lifecycle_full_sequence(tmp_path: Path, capsys):
"""register (lib) → transition → open-position → close all via main([...])
with a date-only --actual-date that persists as a tz-aware date-time."""
# _source_date backdates the IDEA stamp so the fully backdated chain
# (IDEA == ENTRY_READY == ACTIVE == 2026-03-01) stays monotonic.
tid, _ = _register_and_get(tmp_path, _source_date="2026-03-01")
sd = str(tmp_path)
assert (
thesis_store.main(
[
"--state-dir",
sd,
"transition",
tid,
"ENTRY_READY",
"--reason",
"validated",
"--event-date",
"2026-03-01",
]
)
== 0
)
assert (
thesis_store.main(
[
"--state-dir",
sd,
"open-position",
tid,
"--actual-price",
"150.0",
"--actual-date",
"2026-03-01",
"--shares",
"7.86",
"--event-date",
"2026-03-01",
]
)
== 0
)
t = thesis_store.get(tmp_path, tid)
assert t["status"] == "ACTIVE"
assert t["position"]["shares"] == 7.86
# date-only CLI arg widened to tz-aware date-time
assert t["entry"]["actual_date"] == "2026-03-01T00:00:00+00:00"
assert (
thesis_store.main(
[
"--state-dir",
sd,
"close",
tid,
"--exit-reason",
"target_hit",
"--actual-price",
"165.0",
"--actual-date",
"2026-03-20",
]
)
== 0
)
t = thesis_store.get(tmp_path, tid)
assert t["status"] == "CLOSED"
assert t["outcome"]["pnl_dollars"] == round((165.0 - 150.0) * 7.86, 2)
def test_cli_main_attach_and_terminate(tmp_path: Path):
"""attach-position + terminate INVALIDATED via main([...])."""
tid, _ = _register_and_get(tmp_path)
sd = str(tmp_path)
report = _make_position_report(tmp_path)
assert (
thesis_store.main(
[
"--state-dir",
sd,
"attach-position",
tid,
"--report",
report,
]
)
== 0
)
t = thesis_store.get(tmp_path, tid)
assert t["position"]["shares"] == 125
assert (
thesis_store.main(
[
"--state-dir",
sd,
"terminate",
tid,
"--terminal-status",
"INVALIDATED",
"--exit-reason",
"thesis broke",
]
)
== 0
)
assert thesis_store.get(tmp_path, tid)["status"] == "INVALIDATED"
def test_cli_main_existing_subcommands_regression(tmp_path: Path):
"""The pre-existing subcommands still work through the refactored main()."""
tid, _ = _register_and_get(tmp_path)
sd = str(tmp_path)
assert thesis_store.main(["--state-dir", sd, "list"]) == 0
assert thesis_store.main(["--state-dir", sd, "get", tid]) == 0
assert thesis_store.main(["--state-dir", sd, "review-due"]) == 0
assert thesis_store.main(["--state-dir", sd, "rebuild-index"]) == 0
assert thesis_store.main(["--state-dir", sd, "doctor"]) == 0
assert thesis_store.main(["--state-dir", sd, "mark-reviewed", tid]) == 0
# no subcommand → help, non-zero
assert thesis_store.main(["--state-dir", sd]) == 1
def test_transition_event_date_backdates_history(tmp_path: Path):
"""transition(event_date=...) stamps status_history.at, not now."""
tid, _ = _register_and_get(tmp_path, _source_date="2026-03-01")
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "backdated", event_date="2026-03-01")
t = thesis_store.get(tmp_path, tid)
assert t["status_history"][1]["at"] == "2026-03-01T00:00:00+00:00"
def test_transition_without_event_date_regression(tmp_path: Path):
"""Existing callers (no event_date) still stamp ~now and pass."""
tid, _ = _register_and_get(tmp_path)
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok")
t = thesis_store.get(tmp_path, tid)
# IDEA stamped at register (~now), ENTRY_READY at ~now → still monotonic
assert t["status_history"][1]["status"] == "ENTRY_READY"
assert "T" in t["status_history"][1]["at"]
def test_backdate_monotonicity_negative_control(tmp_path: Path):
"""Without --event-date on transition, a later backdated open_position
breaks status_history monotonicity (this is WHY transition gained
event_date)."""
tid, _ = _register_and_get(tmp_path) # IDEA @ ~now
thesis_store.transition(tmp_path, tid, "ENTRY_READY", "ok") # @ ~now
# Full ISO past timestamp → exercises the monotonicity guard (a bare
# date-only would fail the date-time FormatChecker first; the CLI layer
# is what coerces date-only, which is why _coerce_dt exists).
with pytest.raises(ValueError, match="is before"):
thesis_store.open_position(
tmp_path,
tid,
150.0,
"2026-03-01T10:00:00+00:00",
shares=7.86,
event_date="2020-01-01T00:00:00+00:00",
)
# -- Tests: PR-80B partial close (PARTIALLY_CLOSED + shares_remaining + trim) --
def _active_with_shares(tmp_path: Path, shares, entry_price=100.0, **overrides):
"""Register → ENTRY_READY → ACTIVE @2026-05-01 with `shares` (chain
backdated so later-dated trims stay status_history-monotonic).
`**overrides` (e.g. ticker=...) flow to _register_and_get so a single
test can build multiple distinct theses (default _make_thesis_data is
fingerprint-idempotent — same args ⇒ same thesis)."""
tid, _ = _register_and_get(tmp_path, _source_date="2026-05-01", **overrides)
thesis_store.transition(
tmp_path, tid, "ENTRY_READY", "ok", event_date="2026-05-01T00:00:00+00:00"
)
thesis_store.open_position(
tmp_path,
tid,
entry_price,
"2026-05-01T00:00:00+00:00",
shares=shares,
event_date="2026-05-01T00:00:00+00:00",
)
return tid
def test_open_position_sets_shares_remaining(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
t = thesis_store.get(tmp_path, tid)
assert t["position"]["shares"] == 10
assert t["position"]["shares_remaining"] == 10
def test_attach_then_open_position_no_shares_sets_remaining(tmp_path: Path):
"""attach_position() sets shares_remaining; open_position without --shares
must not leave a PR-80B record looking legacy."""
tid, _ = _register_and_get(tmp_path, _source_date="2026-05-01")
thesis_store.transition(
tmp_path, tid, "ENTRY_READY", "ok", event_date="2026-05-01T00:00:00+00:00"
)
report = _make_position_report(tmp_path) # final_recommended_shares = 125
thesis_store.attach_position(tmp_path, tid, report)
t = thesis_store.get(tmp_path, tid)
assert t["position"]["shares_remaining"] == 125
thesis_store.open_position(
tmp_path, tid, 150.0, "2026-05-01T00:00:00+00:00", event_date="2026-05-01T00:00:00+00:00"
)
t = thesis_store.get(tmp_path, tid)
assert t["position"]["shares_remaining"] == t["position"]["shares"] == 125
@pytest.mark.parametrize("end_status", ["PARTIALLY_CLOSED", "CLOSED", "INVALIDATED"])
def test_attach_position_rejected_post_open(tmp_path: Path, end_status):
"""attach_position() must refuse PARTIALLY_CLOSED / CLOSED / INVALIDATED —
re-writing shares_remaining == shares would violate the invariant and
clobber the trim ledger."""
report = _make_position_report(tmp_path)
tid = _active_with_shares(tmp_path, 10, ticker=f"ATCH{end_status[:3]}")
if end_status == "PARTIALLY_CLOSED":
thesis_store.trim(tmp_path, tid, 4, 120.0, "2026-05-10")
elif end_status == "CLOSED":
thesis_store.trim(tmp_path, tid, 10, 120.0, "2026-05-10") # trim-to-zero
else: # INVALIDATED
thesis_store.terminate(tmp_path, tid, "INVALIDATED", "thesis broke")
assert thesis_store.get(tmp_path, tid)["status"] == end_status
with pytest.raises(ValueError, match="attach_position\\(\\) not allowed"):
thesis_store.attach_position(tmp_path, tid, report)
def test_trim_active_to_partially_closed(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
t = thesis_store.trim(tmp_path, tid, 4, 120.0, "2026-05-10")
assert t["status"] == "PARTIALLY_CLOSED"
assert t["position"]["shares_remaining"] == 6
led = t["status_history"][-1]
assert led["status"] == "PARTIALLY_CLOSED"
assert led["shares_sold"] == 4
assert led["price"] == 120.0
assert led["proceeds"] == 480.0
assert led["realized_pnl"] == 80.0 # (120-100)*4
assert led["at"] == "2026-05-10T00:00:00+00:00" # --date persisted
def test_multi_trim_then_close_cumulative(tmp_path: Path):
"""entry 100 / 10sh; trim 4@120 (+80), trim 3@130 (+90), close 3@90 (−30)
→ cumulative pnl_dollars 140, pnl_pct 140/(100*10)*100 = 14.0."""
tid = _active_with_shares(tmp_path, 10)
thesis_store.trim(tmp_path, tid, 4, 120.0, "2026-05-10")
thesis_store.trim(tmp_path, tid, 3, 130.0, "2026-05-15")
t = thesis_store.close(tmp_path, tid, "manual", 90.0, "2026-05-20T00:00:00+00:00")
assert t["status"] == "CLOSED"
assert t["position"]["shares_remaining"] == 0
assert t["outcome"]["pnl_dollars"] == 140.0
assert t["outcome"]["pnl_pct"] == 14.0
assert t["outcome"]["holding_days"] == 19 # 2026-05-01 → 2026-05-20
ledger = [h for h in t["status_history"] if "realized_pnl" in h]
assert [h["realized_pnl"] for h in ledger] == [80.0, 90.0, -30.0]
# exactly one terminal entry, and it is CLOSED
assert sum(1 for h in t["status_history"] if h["status"] == "CLOSED") == 1
assert t["status_history"][-1]["status"] == "CLOSED"
def test_trim_to_zero_closes_with_default_exit_reason(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
thesis_store.trim(tmp_path, tid, 6, 120.0, "2026-05-10")
t = thesis_store.trim(tmp_path, tid, 4, 130.0, "2026-05-15")
assert t["status"] == "CLOSED"
assert t["position"]["shares_remaining"] == 0
assert t["exit"]["exit_reason"] == "manual" # default
assert t["exit"]["actual_price"] == 130.0
assert t["exit"]["actual_date"] == "2026-05-15T00:00:00+00:00"
# (120-100)*6 + (130-100)*4 = 120 + 120 = 240
assert t["outcome"]["pnl_dollars"] == 240.0
# only one CLOSED entry (trim's own ledger entry, not duplicated)
assert sum(1 for h in t["status_history"] if h["status"] == "CLOSED") == 1
def test_trim_to_zero_exit_reason_override(tmp_path: Path):
tid = _active_with_shares(tmp_path, 5)
t = thesis_store.trim(tmp_path, tid, 5, 80.0, "2026-05-10", exit_reason="stop_hit")
assert t["status"] == "CLOSED"
assert t["exit"]["exit_reason"] == "stop_hit"
def test_close_from_partially_closed_is_cumulative(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
thesis_store.trim(tmp_path, tid, 7, 120.0, "2026-05-10") # realized +140
t = thesis_store.close(
tmp_path, tid, "manual", 90.0, "2026-05-20T00:00:00+00:00"
) # remaining 3 @ 90 → (90-100)*3 = −30
assert t["status"] == "CLOSED"
assert t["outcome"]["pnl_dollars"] == 110.0 # 140 − 30
assert sum(1 for h in t["status_history"] if h["status"] == "CLOSED") == 1
def test_trim_guards(tmp_path: Path):
# Distinct tickers — _make_thesis_data defaults are fingerprint-idempotent,
# so identical args would collapse to one thesis.
# not ACTIVE/PARTIALLY_CLOSED (IDEA)
tid, _ = _register_and_get(tmp_path, ticker="GUARDA")
with pytest.raises(ValueError, match="Can only trim"):
thesis_store.trim(tmp_path, tid, 1, 100.0, "2026-05-10")
# ACTIVE but no position/shares (open-position without --shares)
tid2, _ = _register_and_get(tmp_path, ticker="GUARDB", _source_date="2026-05-01")
thesis_store.transition(
tmp_path, tid2, "ENTRY_READY", "ok", event_date="2026-05-01T00:00:00+00:00"
)
thesis_store.open_position(
tmp_path,
tid2,
100.0,
"2026-05-01T00:00:00+00:00",
event_date="2026-05-01T00:00:00+00:00",
)
with pytest.raises(ValueError, match="requires a recorded position"):
thesis_store.trim(tmp_path, tid2, 1, 100.0, "2026-05-10")
# shares_sold > remaining and <= 0
tid3 = _active_with_shares(tmp_path, 5, ticker="GUARDC")
with pytest.raises(ValueError, match="must be > 0 and"):
thesis_store.trim(tmp_path, tid3, 6, 100.0, "2026-05-10")
with pytest.raises(ValueError, match="must be > 0 and"):
thesis_store.trim(tmp_path, tid3, 0, 100.0, "2026-05-10")
def test_trim_fractional_precision_to_zero(tmp_path: Path):
tid = _active_with_shares(tmp_path, 7.86)
t = thesis_store.trim(tmp_path, tid, 4.00, 120.0, "2026-05-10")
assert t["status"] == "PARTIALLY_CLOSED"
assert t["position"]["shares_remaining"] == 3.86
t = thesis_store.trim(tmp_path, tid, 3.86, 130.0, "2026-05-15")
assert t["status"] == "CLOSED"
assert t["position"]["shares_remaining"] == 0 # epsilon-snapped
def test_transition_into_partially_closed_blocked(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
with pytest.raises(ValueError, match="Use trim\\(\\)"):
thesis_store.transition(tmp_path, tid, "PARTIALLY_CLOSED", "nope")
def test_partially_closed_requires_shares_remaining(tmp_path: Path):
"""A PARTIALLY_CLOSED thesis with no shares_remaining is rejected (no
legacy leniency for this PR-80B-only status)."""
tid = _active_with_shares(tmp_path, 10)
thesis_store.trim(tmp_path, tid, 4, 120.0, "2026-05-10") # → PARTIALLY_CLOSED
t = thesis_store.get(tmp_path, tid)
del t["position"]["shares_remaining"]
with pytest.raises(ValueError, match="requires position.shares_remaining"):
thesis_store._validate_thesis(t)
def test_closed_shares_remaining_zero_passes_schema(tmp_path: Path):
"""Regression for the minimum:0 schema fix — a CLOSED thesis persisting
shares_remaining == 0 must NOT be rejected at the JSON-Schema layer."""
tid = _active_with_shares(tmp_path, 10)
thesis_store.trim(tmp_path, tid, 10, 120.0, "2026-05-10") # full close-out
t = thesis_store.get(tmp_path, tid) # get() implies schema-valid
assert t["status"] == "CLOSED"
assert t["position"]["shares_remaining"] == 0
thesis_store._validate_thesis(t) # explicit: no raise
def test_legacy_active_without_shares_remaining_valid(tmp_path: Path):
"""A legacy ACTIVE thesis (no shares_remaining key) still validates."""
tid = _active_with_shares(tmp_path, 10)
t = thesis_store.get(tmp_path, tid)
del t["position"]["shares_remaining"]
thesis_store._validate_thesis(t) # ACTIVE leniency: no raise
def test_terminate_invalidated_no_price_unchanged(tmp_path: Path):
"""attach-position then terminate INVALIDATED with no price → one plain
INVALIDATED entry, no P&L, shares_remaining untouched (pre-PR-80B path)."""
tid, _ = _register_and_get(tmp_path, _source_date="2026-05-01")
thesis_store.transition(
tmp_path, tid, "ENTRY_READY", "ok", event_date="2026-05-01T00:00:00+00:00"
)
report = _make_position_report(tmp_path)
thesis_store.attach_position(tmp_path, tid, report)
thesis_store.open_position(
tmp_path, tid, 150.0, "2026-05-01T00:00:00+00:00", event_date="2026-05-01T00:00:00+00:00"
)
t = thesis_store.terminate(tmp_path, tid, "INVALIDATED", "thesis broke")
assert t["status"] == "INVALIDATED"
assert t["outcome"]["pnl_dollars"] is None
assert t["position"]["shares_remaining"] == 125 # untouched
assert sum(1 for h in t["status_history"] if h["status"] == "INVALIDATED") == 1
assert "realized_pnl" not in t["status_history"][-1]
def test_terminate_invalidated_from_partially_closed_cumulative(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
thesis_store.trim(tmp_path, tid, 6, 120.0, "2026-05-10") # realized +120
t = thesis_store.terminate(
tmp_path,
tid,
"INVALIDATED",
"broke",
actual_price=90.0,
actual_date="2026-05-20T00:00:00+00:00",
) # remaining 4 @ 90 → (90-100)*4 = −40
assert t["status"] == "INVALIDATED"
assert t["outcome"]["pnl_dollars"] == 80.0 # 120 − 40, not double-counted
assert sum(1 for h in t["status_history"] if h["status"] == "INVALIDATED") == 1
def test_cli_trim_subcommand(tmp_path: Path):
tid = _active_with_shares(tmp_path, 10)
sd = str(tmp_path)
rc = thesis_store.main(
[
"--state-dir",
sd,
"trim",
tid,
"--shares-sold",
"4",
"--price",
"120",
"--date",
"2026-05-10",
]
)
assert rc == 0
t = thesis_store.get(tmp_path, tid)
assert t["status"] == "PARTIALLY_CLOSED"
assert t["position"]["shares_remaining"] == 6
assert t["status_history"][-1]["at"] == "2026-05-10T00:00:00+00:00"
Related skills
FAQ
What lifecycle stages does trader-memory-core track?
trader-memory-core follows each thesis from screener registration through analysis, position sizing, active management, review due dates, and closed-position postmortems with P&L and MAE/MFE reporting.
Which phrases activate trader-memory-core?
trader-memory-core triggers on register thesis, track this idea, thesis status, review due, close position, postmortem, and trading journal requests, keeping one persistent thesis object per idea.