diff --git a/.gitignore b/.gitignore index 25966ec1..92bfcf23 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ skills/fomo-kernel/dist/ # ledger.jsonl 當預設輸入讀)——延續 *.csv 只留 mock 例外的隱私鐵律精神 ledger.jsonl .trade-coach/ +# #403 同一條防線:position_rationales.jsonl 是使用者自己的原話,比帳本更私人。 +# 同樣寫在 coach root 底下,同樣會被從 repo 根目錄啟動的 dogfood run 掉在這裡。 +position_rationales.jsonl # #214 snapshot envelope backstop: *.json cannot be blanket-ignored (it would # swallow schemas and fixtures), so pin the exact recommended temporary diff --git a/AGENTS.md b/AGENTS.md index cf54f83e..d0b78c21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Test drive (`prepare --test-drive`) runs in an isolated root: pass `--root '` and the question is refused rather than answered on cost basis — deliberately the opposite of the review-card lane, which delivers its degraded card. 9. A decision the user brings with no recorded book and no snapshot is framed, not refused. `consider` still fails closed for want of a book — boundary 6 is unchanged — and the answer that follows is a separate bounded outcome under `skills/fomo-kernel/references/decision-framing.md`: at most three questions, no computed or placeholder portfolio number anywhere in it, a user-declared size treated as an input and never as a record, nothing written to durable state, and every limitation that matters shaped as a question the user can answer rather than a gap narrated back at them. Refusing is not what earns a transaction history — naming the specific answer the next piece of evidence would buy is. diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 2af602bf..66413489 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -135,6 +135,7 @@ When multiple sessions are active: | HTML card design system | `card-template.html` is generated by `tools/gen_card_template.py` from `card-template.src.html` plus the runtime's own `_HTML_SHIM_CSS` / `_HTML_WIDGET_CSS` literals in `card_renderer.render_html()` (#401) — it is not hand-mirrored, so it cannot disagree with the runtime on palette, dark-mode rules, or layout constraints; `tests/test_card_html.py::test_card_template_matches_its_generator` fails the suite if the committed file and a fresh regeneration disagree. Design-provenance prose and template-only illustrative CSS (a few icons, one accessibility idea the runtime does not implement yet) are authored in `card-template.src.html`, not in the generated file. Before adding or reshaping a visual element, read [design-guidelines.md](design-guidelines.md): it defines the module slot contracts that keep one element's change from forcing its neighbours to be re-tuned. | | Card copy on the branches no persona reaches | `skills/fomo-kernel/copy/*.json` ↔ `tests/golden/copy-corpus.txt`, generated by `tests/copy_corpus.py --update` from real `render_private`/`render_html`/`render_public` output (#402 knife 5). Never hand-edit the golden, and never pin a catalog sentence as a literal inside a test again: add a scene instead. `tests/copy_corpus.py` also fails when a key in a claimed register reaches no surface, so dead copy and a renderer that silently stopped emitting a sentence both surface as red rather than as a passing suite. | | Design-bundle preview CSS | `tools/design_bundle.py` `TOKENS`/`CARD` (`.rc2`/`--` aliases) are derived at build time from `_HTML_WIDGET_CSS` / `_HTML_SHIM_CSS`, not hand-copied. Rerun `python3 skills/fomo-kernel/tools/design_bundle.py` after a runtime CSS change to refresh `ds-bundle/`. | +| Canonical position-rationale stream (#403 Front B, #450 reader) | `skills/fomo-kernel/engine/position_rationale.py` is the only writer, fold and reader of `/position_rationales.jsonl` ↔ `schemas/position-rationale.schema.json` (the readable contract; the module holds the enforcement, as the offline suite carries no jsonschema dependency) ↔ `coach.DATA_FILES` ↔ `tests/test_position_rationale.py` + the round-trip in `tests/test_coach_data_cli.py`. The event contract is the owner's accepted 2026-07-31 disposition, which superseded #403's body on two details. **The predecessor is in the content address**: without it a same-day A→B→A→B sequence hashes the fourth statement onto the second and drops it as a retry while the writer reports success. **Idempotency is head comparison, not id collision**: the chain alone breaks the retry it exists to preserve, because a write that lands and dies before its receipt re-reads a head that is now its own row — the shape `review._append_evaluation_row` already uses. Those two are one change; landing either alone is worse than landing neither. **Position in the chain is derived, never stored**, and derived in exactly one place — `classify()`, surfaced as `query()["change"]`; a second consumer deriving it independently is the defect not storing it was meant to end. **Three ambiguities refuse rather than resolve**: a subject with two children of one predecessor (`forks_in`), an `expected_predecessor` that is no longer the head, and a review session returning different words under an id it already used (`session._append_session_rows`' shape) — each could be settled by picking a winner, and every winner is one the user never nominated. **The reader follows engine-proven relinks** (`aliases=`), because #563 moves a cycle's start and a reader matching only the current id would let a position look remembered and then silently lose its reason; identity stays `thesis.py`'s to prove, so the caller passes the relinks rather than this module guessing. `stated_at` defaults to the local calendar date and `recorded_at` is stamped after the identity, never inside it. #450 froze `total_count`/`shown_count` — do not shorten them to `_evaluation_reconciliation`'s local spelling. `mapping_status` is deliberately not stored and is the one item open on #403: monitorability is `conditions.py`'s three-state answer, and a boolean beside `condition_ref` could not carry the middle state. **Two entrances, one writer.** `review.py record-rationale` resolves a ticker to one active cycle through `_resolve_rationale_subject` and appends directly; the review lane's `rationale_refresh` answer calls the *same* `append_locked` inside the finalize transaction rather than becoming a projected bundle key, because a second writer into a canonical stream would be the parallel store #403 forbids. The review lane is therefore idempotent on `(capture_source, session_id, subject)`, and that check runs **before** the `expected_predecessor` guard — a re-finalize reads a head its own first run moved, so the other order turns a documented-safe retry into a refusal. A rationale that cannot be recorded is receipted as `rationale_error` beside a card that still commits (the owner's 2026-07-31 ruling on independent outcomes), which is also why `finalize` sits in `test_split_basis.py`'s `_ROUTES_NOT_DRIVEN`: it reads no share count, cost or weight, and its one failure mode cannot reach the card. The plan surface is `state_snapshot.position_rationales` + its summary, attached unconditionally so "nothing recorded" is a claim rather than a missing key — which is also what makes the `state:position_rationales` eval consumer claim verifiable with no fixture changes. `RATIONALE_REFRESH_MIN_DAYS` keeps the question rare on purpose: #403's value is a series over quarters, and a question that arrives too often trains the user to answer it without thinking. A forked subject is never asked about. **What is still not built**: the light-capture migration, condition mapping, and `consider` recall — and `user_statement` is user-authored free text now reaching `state_snapshot`, so the next slice that touches a public card surface owns keeping it out. | | Coach-root persistence registry (#452) | `coach.py`'s `DATA_FILES` (name, kind, user-facing description) is the only registry — `data-status`/`data-export`/`data-reset` all read it through `_scan_root(root)`, never a second list. Completeness is generated, not hand-verified: `tests/test_coach_data_cli.py`'s `test_data_files_registry_covers_every_engine_written_path` parses every non-test module under `skills/fomo-kernel/engine/` for the `os.path.join(, "")` idiom every writer already uses, and fails if a literal it finds is not registered — `condition_checks.jsonl` was exactly this gap (shipped in #412's second half, never registered, nothing failed until this check existed). `review.py`'s `_engine_version()` is named `repo_root` rather than `root` specifically so the scan needs no hand-written exclusion for the skill's own checkout path. Two already-registered entries the scan cannot see — `profile.md` (the agent writes it directly per `SKILL.md`) and `ux/` (`tools/ux_receipt.py`'s pathlib writer, outside `engine/`) — are covered instead by their own dedicated tests in the same file, not by this one. | | Pre-trade evaluation (Layer 2, `review.py consider`) | `schemas/trade-evaluation.schema.json` (`premise` is `$ref`-ed straight from `trade-premise.schema.json`, `context` from `schemas/decision-context.schema.json`, and `agent_case` from `schemas/answer-provenance.schema.json` — never restated) ↔ `review.py`'s `cmd_consider` / `_rows_from_ledger` (the ledger-reconstruction fallback for a caller with no CSV in hand) / `_evaluation_id` / `_append_evaluation_row` / `_validate_decision_context` / `_validate_agent_case` ↔ `engine/answer_provenance.py`'s `validate_agent_case` (#414; the semantic gate `cmd_consider` calls after `consequence`/`rule_collisions` exist and before either is persisted or returned — #479 Wave B) ↔ `coach.py`'s `DATA_FILES` (so `data-export`/`data-reset` see `trade_evaluations.jsonl` like every other stored file — #452 is exactly the bug that shipped when a file skipped this step) ↔ `references/trade-consequence.md` ↔ `tests/test_consider.py`. Every stored field is a frozen value, never a pointer into mutable state the ledger keeps growing past — the frozen-subject design ratified in issue #446's specification comment. `--resolve` appends a new row carrying the same `evaluation_id` rather than rewriting the old one; `_fold_evaluations` (mirroring `conditions.fold_slots`'s supersede-by-chain precedent) is the only reader that decides which row is current. `review.CONSIDER_DECISIONS` and the schema's `decision` enum are locked together by a drift test in `tests/test_consider.py`, the same discipline the question-kind enum row above holds; so are `EVALUATION_EVIDENCE_REFS_CAP` / `EVALUATION_CONTEXT_TEXT_MAX` / `EVALUATION_EVIDENCE_REF_MAX` and the context schema's own `maxItems`/`maxLength`. Three rules the DecisionContext (#479 Wave A) adds. **Absent means absent**: `_evaluation_id` omits the `context` key from its seed and `cmd_consider` omits it from the row on the identical `is not None` test — a seed carrying `"context": null` moves the hash of *every* context-free call, so an existing user's next plain re-ask mints a new id instead of converging on the row already on disk, duplicating it, with nothing else in the suite pinning an id value. `test_a_context_free_evaluation_id_is_exactly_what_it_was_before_context_existed` pins the literal digest this function returned at `main@52df7f9`; that value must never move. **Identity, never arithmetic**: the context seeds the id so the same premise re-asked with a different `why_now` is a distinct evaluation, and `consequence`/`rule_collisions` are computed from the premise and the book before the seed is taken — the paired test asserts both halves at once, because seeding on the context is only legitimate while the arithmetic is byte-identical. **Every bound refuses, none truncates**: a shortened reason or a clipped evidence list is a statement the user never made, so `_validate_decision_context` raises with the limit named rather than repairing the envelope. `linked` stays out of `CONSIDER_DECISIONS` and out of the `decision` enum — that tuple is `--decision`'s argparse `choices`, so admitting it would let a user assert a link the engine never made (#490 derives link status at read time from its own stream and never writes it back here). Two rules #479 Wave B's provenance-gate integration adds, on top of #414's already-frozen validator. **The gate reads exactly what gets stored, and reads it once**: `cmd_consider` calls `validate_agent_case` with the same `consequence_stored`/`collisions` objects the row itself carries, never a separately assembled copy, and passes `user_statements=(context["reason"], context["why_now"])` — the exact, unparaphrased strings, never a summary — when a `--decision-context` was supplied, `()` otherwise; `agent_case` still never enters `_evaluation_id`'s seed, matching the reasoning the `context` row above already states: it is the agent's interpretation, not the subject being evaluated. `_validate_agent_case` (the cheap structural precheck that still runs first) was narrowed to require only that a claim carry `claim`/`provenance`, not *exactly* those two fields — the full field set is provenance-dependent (`anchor`/`worsens` for `engine_fact`, `source`/`as_of` for `public_fact`) and is now checked in exactly the one place that also enforces it, `answer_provenance.py`. **Reconciling the two claim shapes was Wave B's call, made once, not left open**: `trade-evaluation.schema.json`'s `agent_case` property is a bare `$ref` to `answer-provenance.schema.json` rather than a second, narrower claim `$defs` of its own — the old inline shape (`additionalProperties: false`, only `claim`/`provenance`) could not express what `engine_fact`/`public_fact` claims are required to carry at all, so leaving it unreconciled would have made both of those provenance kinds permanently unusable through the CLI even after the validator was wired in. What `consequence.DISCLOSURES` may contain is settled in one place and read three times (#598/#599/#600): the constant itself, `trade-evaluation.schema.json`'s stored enum, and `evaluation-challenge.schema.json`'s live one, locked by two drift tests in `tests/test_consider.py`. The stored enum is `DISCLOSURES` **plus** `RETIRED_DISCLOSURES` and nothing else — a key no path emits stays valid on a row written before it stopped being emitted, the replay posture #549's `declared_partial` already has — while the challenge enum is `DISCLOSURES` alone, because that block is emitted fresh and never stored, so offering a retired key there would tell an agent it may owe a disclosure nothing can produce. Three rules about what the keys themselves are for. **A blind spot is disclosed with its size, never only its existence**: `unclassified_book`/`etf_not_decomposed` name the *book's* illegible positions where `unmapped_driver` had only ever looked at the premise's own ticker, and each carries an identity list beside it (`unclassified_holdings`, `undecomposed_etfs`) on #515's exact division of labour — the key says THAT the concentration figures were measured over part of the book, the list says WHICH positions and at what weight. Both feed `answer_provenance.required_coverage`, which reads `disclosures` generically, so adding a key is what makes an answer owe the fact; the enforcement is free and the naming is the whole decision. The lists are stamped by `consequence.book_legibility`, called from `portfolio_state` rather than from `consequence()`, so a state carries its concentration readings and the positions those readings were measured *without* in the same dict — `consequence()` forwards `after`'s pair instead of deriving a second one, and `review._canonical_consider_before` is the only other call site, recomputing because it is the one place a state's denominator is replaced after the state was built. Placing this in `consequence()` alone was the shipped defect: a probe workflow that read the book and printed `ai_pct` received the reading with none of its limitations, offline suite green — the same failure shape as a reader of the book not being handed the split map, and fixed the same way. **The two lists are disjoint and the split is the remedy, not tidiness**: a fund the instrument map does not recognize is an unclassified single name, where `--driver-map` is the fix, and declaring it a fund moves it to the limitation that is true, where no fix exists yet — naming one position twice would point the user at a remedy that cannot work. Nothing here is look-through; #599 still owns prorating a fund's constituents across sector/AI buckets. **A currency gap is not a disclosure at all**: `portfolio_state` refuses a book whose held currency has no rate, because `usd_view` resolves a missing one as 1.0 and a ~31:1 currency summed at face value does not make the aggregate incomplete, it inverts which holding is the largest — AGENTS.md boundary 6's fail-closed rule, and #497's canonical-lane treatment finally reaching the legacy CSV lane that never had it. `_canonical_consider_before` converts that `ConsequenceError` rather than letting it escape, and `portfolio_state` carries no `fx_gaps` companion any more: past the refusal it could only ever be empty. One rule about the price day the basis freezes (#618). **A date is recorded only where one was observed, per instrument, and the summary is derived from the instruments rather than declared over them**: `basis.price_observations` is stamped by `review._price_observation_record` from the parsed feed's own `observed_date`, scoped to the same `priced_universe` the recovery kit is built over, and is *absent* — never null, never a placeholder, never today's — whenever `valuation_basis` is `unpriced`, which is what keeps an offline answer from growing a date it never had. Its `as_of` is `max(by_ticker)` and not `feed["as_of"]`: the envelope's declared frame date is an upper bound `price_feed.parse` enforces on its rows, so a supplied envelope may legitimately declare today over yesterday's closes, and copying it forward would put the summary ahead of every number it summarizes — the same confusion at frame level that per-ticker observation exists to prevent (#583 §2), and the definition `market_data.to_price_feed_envelope` already takes for its own `as_of`. A priced call's `evaluation_id` moves because of this field and that is correct — the id seeds on the frozen answer and the answer genuinely differs — while a context-free *unpriced* call's must not, which `test_a_context_free_evaluation_id_is_exactly_what_it_was_before_context_existed`'s literal digest is what pins. | | Pre-trade evaluation → review reconciliation | `review.py`'s `_evaluation_reconciliation` (calls `_fold_evaluations`, and reads real dated trade events through `_ledger_trade_events` — **never** `_rows_from_ledger`, whose synthesized anchor row raises on a position declared with shares alone and would break `prepare` for an ordinary snapshot user) ↔ `evaluation_reconciliation` in `schemas/review-plan.schema.json` (declared, `additionalProperties: false` at every level, deliberately not dropped into schema-open `state_snapshot`; optional rather than `required`, matching the `engine_version`/`authoring_contract` replay-compatibility precedent) ↔ `EVALUATION_RECONCILE_CAP` and its `summary.beyond_cap` disclosure ↔ `references/trade-consequence.md` ↔ `tests/test_consider.py`. Two invariants. **The engine states a fact, never a cause**: a matching trade may be reported, but `decision` is the user's word and moves only through `consider --resolve` — an engine-written decision would manufacture an adjudication nobody made, the prohibition `condition-check.schema.json` states about `user_response`. **A capped list discloses what it dropped**, or a bounded read reads as a complete one. This is the consumer that keeps `trade_evaluations.jsonl` from being a written-never-read store (#429). **It is no longer the only reader outside `consider`**, and the second one deliberately shares none of its filters: `_evaluation_recall` (#636) reads the same store to quote the user's own `context` words back into the `initial_thesis` question stem, and ignores `decision` entirely — a resolved evaluation is still something the user said, so this row's open-only scope is the wrong one for recall, and this surface's own schema declares itself a fact surface rather than a question. Its bounds live with it: `_cycle_entry` matches `trade_recap.CYCLE_ID_RE` instead of splitting on `#`, and `_recalled_entry_statement` fails closed on any cycle after the first, because a cycle id carries no lower bound and a re-entry would otherwise quote the previous position's reason. A third reader of this store must state which of those two filter sets it takes and why. | diff --git a/evals/run_episodes.py b/evals/run_episodes.py index b98c7fba..a7b93368 100644 --- a/evals/run_episodes.py +++ b/evals/run_episodes.py @@ -191,6 +191,14 @@ def _load_module(name, path): # writes a new slot row on the line — which is what the *next* review's due # list carries forward, criterion and all. "condition_basis": ("condition_checks", ("card", "state:condition_slots_due")), + # #403: the answer is written straight into the canonical + # position_rationales.jsonl through the one append service, not projected + # out of the session bundle the way every other kind's is -- a second writer + # into a canonical stream would be the parallel store the issue forbids. + # So the verifiable consumer is the plan key the *next* review reads it back + # through, and there is deliberately no card claim: #403 parks card layout, + # and a rationale is a fact surface rather than a card section. + "rationale_refresh": ("position_rationales", ("state:position_rationales",)), } # Kinds whose answer reaches nothing, each pinned to the issue that owns the # disposition. A ratchet, not an exemption: a kind that becomes wired must leave diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index 653f472e..ae45e6d8 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -12,7 +12,7 @@ The product's value is continuity: this review reconciles against the last one, ## Non-negotiable rules 1. **Numbers come from engine artifacts only.** Never calculate, fill in, or adjust a numeric fact, ranking, weight, or identity. Determinism is what makes week-to-week reconciliation trustworthy: this week's 12% and next week's 12% have to be the same 12%. -2. **Invoke the engine only through the `engine/review.py` CLI** (`prepare`, `resume`, `preview`, `finalize`, `capture`, `consider`, `refresh`, `positions`, `render`, `repair-projections`, `set-cap`, `mute-rule`, `add-cash`, `resolve-market-data`, `doctor`). Do not call another `engine/*` script or import engine modules directly — those paths bypass lifecycle validation, required-question gates, and canonical session state. +2. **Invoke the engine only through the `engine/review.py` CLI** (`prepare`, `resume`, `preview`, `finalize`, `capture`, `consider`, `refresh`, `positions`, `record-rationale`, `render`, `repair-projections`, `set-cap`, `mute-rule`, `add-cash`, `resolve-market-data`, `doctor`). Do not call another `engine/*` script or import engine modules directly — those paths bypass lifecycle validation, required-question gates, and canonical session state. 3. **A considered trade earns a computed case, never a prose one.** In a review, discuss behavior, motives, thesis evolution, and process rules; the card's prescription still never names what to buy or sell. For a trade the user is deciding on, `consider` returns what it does to their own book and which of their own rules it would break — build the case for and against from that output, mark every claim you added as your own judgment, cite an engine fact through the anchor `references/trade-consequence.md` documents so it can be checked against the frozen result, and state everything the response's own `challenge` block says this answer owes — the facts, the user's exact words unreworded, the rules this trade collides with, and what nobody checked. That block is computed per call, so none of it is a list to remember. Shape the reply decision-first: one supported decision tension leads and everything owed groups around it, per the decision-first section of `references/trade-consequence.md`. A case that misquotes the record, leaves an owed fact uncovered, or relabels the user's own words as an outside source is refused rather than stored. Current market context — the standing position packet and any event verification — enters only under `references/market-lookup.md`'s bounded lookup contract, as sourced public facts. The decision stays theirs, and a price target or market forecast is still not something this product states. 4. **Never invent, interpolate, or recall a market price.** When a host blocks the engine's own retrieval, `review_plan.input.price_feed.request` names what is unpriced; look those closes up from a recognized market-data source, transcribe them into the envelope in `references/price-feed.md`, and rerun `prepare --prices`. A price you cannot find stays missing — a missing price is never a delisting verdict or a zero return. 5. **Trade data stays local.** The source CSV, session bundles, and the ledger never enter cloud memory. The review card is private to the user: show `card-private.*` by default per `references/card-delivery.md`. Local files, terminal output, and private-by-default in-client rendering are all fine; publishing, posting, or sending it to a third party is not. `card-public.md` is the one share-safe artifact, and only if the user asks. diff --git a/skills/fomo-kernel/copy/en.json b/skills/fomo-kernel/copy/en.json index 42a989a5..de6e3824 100644 --- a/skills/fomo-kernel/copy/en.json +++ b/skills/fomo-kernel/copy/en.json @@ -1,326 +1,397 @@ { - "language": "en", - "title": "Review Card", - "private_badge": "Review card; stored locally only", - "public_badge": "Shareable version; amounts, dates, tickers, and exact weights removed", - "public_custom_rule": "One self-authored process rule was committed; its wording stays on the review card.", - "demo_badge": "Demo data and rehearsal only; not your trades and not written to formal coach memory", - "markdown_summary": { - "risk": "Key risk", - "next": "Next rule" + "account_gate": { + "cash_residual": "Update the cash position for that stretch, and you'll see the account-level return with cash included.", + "chain_unavailable": "No day in this window produced a usable account value to chain into a return.", + "default": "Complete the cash anchor, and you'll see the account-level return with cash included.", + "mixed_trade_footprint": "Only part of the trade history carries the broker's cash amount, and the two bases cannot share one cash history.", + "negative_cash_rollback": "Anchor the currency that has none yet, and you'll see the account-level return with cash included.", + "no_cash_anchor": "Add one current cash balance, and you'll see the account-level return with cash included." }, - "snapshot_numbers": "Opening portfolio structure", - "snapshot_strength": "What this snapshot establishes", - "snapshot_hole": "The leading structural risk", - "sections": { - "performance": "Relative performance", - "strength": "One thing you did well", - "hole": "The biggest behavioral leak", - "etf": "ETF and portfolio structure", - "rule": "Change only this next time" + "account_perf": { + "account_base": "Account-level cumulative return was {value}", + "annualized_suffix": "; annualized return was {value}", + "cash_drag_suffix": "; the gap versus the holdings pillar, {value}, is explained by holding cash — an observation, not a verdict", + "holdings_only": "Holdings-only cumulative return was {value}.", + "terminator": "." }, - "blocks": { - "performance": "Performance", - "trades": "Key trades · ranked by money impact", - "risks": "Risks and problems", - "next": "Next step", - "summary": "Summary" + "add_choices": { + "new_evidence": "New testable evidence", + "planned_tranche": "A pre-planned tranche", + "price_only": "Only the price fell / lower the cost basis", + "skip": "Skip for now", + "valuation_change": "The valuation or odds changed" }, - "period": { - "span": "Review period {start} → {end}", - "as_of": "As of {end}", - "vix": "VIX {value}" + "add_descriptions": { + "new_evidence": "Requires a claim and source that a later review can test.", + "planned_tranche": "The tranche schedule existed before the price move.", + "price_only": "No new fact; the main motive was lowering the cost basis or getting back to even.", + "skip": "Leave the motive unclassified for now.", + "valuation_change": "The thesis is unchanged, but price changed the odds or margin of safety." + }, + "alpha_interval": { + "below_negative": "The annualized α interval includes a negative value, meaning this period's stock-picking edge is not yet statistically confirmed as a durable skill.", + "below_unreliable": "* The annualized α is not yet statistically credible.", + "lead": "Risk-adjusted alpha{scope} was {alpha}% annualized, with a 95% interval from {low}% to {high}%; the interval controls how strong the conclusion may be.", + "negative_caveat": " (The interval includes a negative value, meaning this period's stock-picking edge is not yet statistically confirmed as a durable skill.)", + "scope_suffix": " for {market} holdings", + "tile_sub": "95% interval {low}% to {high}%" + }, + "asked_because": { + "exit_notional": "it is one of your largest recent exits by amount", + "pnl_impact": "it is the position with the largest P&L impact this week", + "position_cost": "it is your largest position by cost this week" + }, + "benchmark_line": { + "beta_suffix": "; β {beta}", + "comparator_default": "their market benchmark", + "line": "{subject} beat {comparator} by {excess} pp{beta}.", + "subject_default": "The holdings", + "subject_market": "{market} holdings" + }, + "best_strength": { + "averaging_down": "you added down {count} times and still stayed inside your own position limit, never letting one name grow heavy on the way down{example}", + "averaging_down_example": " (for example {ticker})", + "diversification": "{n} names spread across different drivers, so you are not all-in on one story", + "exit_discipline": "you take profit without flinching: only {winner_early} of the winners you sold kept climbing afterwards, so you are not the kind who clings to a gain until it gives itself back", + "holding_period": "your entries and exits keep a consistent rhythm: a median hold of {median_hold} days, not random buying and selling", + "no_signal": "No positive behavior was strong enough to claim; keep attention on the largest leak.", + "position_sizing": "single-position size is under control: even your heaviest name is only {max_pct}, so your net worth is not riding on one ticker" }, "block_missing": { "absolute_pnl": "Absolute P&L is not computable this period: no trades or valuation basis.", "annualized": "Annualized return is not computable this period: the cash-flow anchor is incomplete.", "annualized_prices": "Annualized return is not computable this period: current prices could not be retrieved, so no portfolio-level valuation exists.", - "vs_market": "Benchmark comparison is not computable this period: no resolvable benchmark series.", - "vs_market_prices": "Benchmark comparison is not computable this period: the benchmark prices could not be retrieved.", - "trades": "No ranked instrument diagnosis this period.", - "trades_traded": "No ranked instrument diagnosis this period; traded: {tickers}.", + "annualized_reconciliation": "Annualized return is not computable this period: the imported transactions did not reproduce the snapshot-anchored holdings, so no account valuation basis exists. Share your current holdings view and the difference can be settled.", + "annualized_short_series": "Annualized return is not computable this period: the price series covers fewer than two days, so there is no change to measure.", "risks": "No behavioral diagnosis this period: the engine scored no dimension.", "rule": "No new rule commitment this time; you can pick one at the next review.", - "rule_standing": "No rule change this period; the standing rule remains: \"{rule}\"", - "rule_pending": "No rule chosen yet this period; the recommendation is: \"{rule}\"", "rule_diverged": "The standing rule is still \"{standing}\"; this period's leading issue is a different one, so \"{recommendation}\" stays a recommendation until you choose it.", + "rule_insufficient_data": "The sample is still short, so this review sets a baseline without forcing a commitment.", + "rule_pending": "No rule chosen yet this period; the recommendation is: \"{rule}\"", "rule_skip": "You chose not to set a new commitment; the same baseline remains available next time.", "rule_snapshot": "This opening check keeps the structural baseline without forcing a commitment.", + "rule_standing": "No rule change this period; the standing rule remains: \"{rule}\"", "rule_structural": "This is an opening structural check: it sets a baseline without forcing a commitment. Too few closed trades here to say yet whether your selling costs you more than your buying; a file carrying the matching sells would settle that one question.", - "rule_insufficient_data": "The sample is still short, so this review sets a baseline without forcing a commitment.", "snapshot_unlock": "This check can see what you hold, not how you got here. Your transaction history would answer the one question it cannot: whether your selling costs you more than your buying.", - "annualized_short_series": "Annualized return is not computable this period: the price series covers fewer than two days, so there is no change to measure.", - "annualized_reconciliation": "Annualized return is not computable this period: the imported transactions did not reproduce the snapshot-anchored holdings, so no account valuation basis exists. Share your current holdings view and the difference can be settled." - }, - "account_gate": { - "default": "Complete the cash anchor, and you'll see the account-level return with cash included.", - "no_cash_anchor": "Add one current cash balance, and you'll see the account-level return with cash included.", - "mixed_trade_footprint": "Only part of the trade history carries the broker's cash amount, and the two bases cannot share one cash history.", - "negative_cash_rollback": "Anchor the currency that has none yet, and you'll see the account-level return with cash included.", - "cash_residual": "Update the cash position for that stretch, and you'll see the account-level return with cash included.", - "chain_unavailable": "No day in this window produced a usable account value to chain into a return." - }, - "footnote_label": "Data notes", - "kpi": { - "pnl": "Total P&L", - "pnl_sub": "{realized}, {unrealized} unrealized", - "payoff": "Realized payoff ratio", - "payoff_sub": "{win} win vs {loss} loss", - "excess": "Benchmark excess", - "excess_sub": "β {beta}", - "alpha": "Annualized alpha", - "alpha_unreliable": "not yet statistically credible", - "curve": "Path this period", - "spark_caption": "peak {peak} · trough {trough}" - }, - "etf_classification": { - "allocation": "Diversified allocation ETFs were separated from single-name concentration: {list}.", - "concentrated": "Sector, thematic, or leveraged ETFs remained concentration risk: {list}." + "trades": "No ranked instrument diagnosis this period.", + "trades_traded": "No ranked instrument diagnosis this period; traded: {tickers}.", + "vs_market": "Benchmark comparison is not computable this period: no resolvable benchmark series.", + "vs_market_prices": "Benchmark comparison is not computable this period: the benchmark prices could not be retrieved." }, - "dimensions": { - "exit_discipline": "exit discipline", - "position_sizing": "position sizing", - "diversification": "portfolio diversification", - "holding_period": "holding-horizon consistency", - "averaging_down": "averaging-down discipline", - "alpha_beta": "benchmark attribution", - "entry_style": "entry style" + "blocks": { + "next": "Next step", + "performance": "Performance", + "risks": "Risks and problems", + "summary": "Summary", + "trades": "Key trades · ranked by money impact" }, - "public_patterns": { - "exit_discipline": "The highlighted behavior concerned how exit decisions were timed and evaluated.", - "position_sizing": "The highlighted behavior concerned how risk was distributed across position sizes.", - "diversification": "The highlighted behavior concerned whether the portfolio represented distinct underlying risks.", - "holding_period": "The highlighted behavior concerned whether holding durations matched a consistent decision horizon.", - "holding_period_same_day": "All completed trades were same-day; no overnight holding.", - "averaging_down": "The highlighted behavior concerned how additions to losing positions were bounded.", - "alpha_beta": "The highlighted behavior concerned how benchmark-relative results were attributed between exposure and selection.", - "entry_style": "The highlighted behavior concerned when entries occurred relative to recent price moves." + "breach_evidence": { + "joiner": "; ", + "more": "; and {extra} more", + "none": "a recorded event" }, - "rules": { - "exit_discipline": "Before exiting, name the fact that completed or broke the thesis. Fear of giving back gains is not enough.", - "position_sizing": "Cap any single position at {cap}. Trim if it goes over, and do not add.", - "diversification": "Before adding a position, check whether it is still the same underlying driver. If that bet is already too large, do not add.", - "holding_period": "Label the trade as short-term, swing, or long-term at entry. Exit only for a reason from the same horizon.", - "averaging_down": "Before averaging down, write one piece of evidence you did not know at entry. If you cannot, do not add." + "candidate_comparison": { + "joiner": " and ", + "line": "{top} scored higher than {others} on this period's severity ranking -- that reflects which pattern showed up more strongly this period, not which rule is the right fit for you." }, - "rule_grounding": { - "position_sizing": "This period: your largest single position {ticker} is {pct} of the portfolio.", - "diversification": "This period: your top positions {tickers} together hold {pct}.", - "holding_period": "This period: {tickers} mixed quick exits and long holds within the same position.", - "averaging_down": "This period: you averaged down {count} times into losing positions, including {tickers}." + "cash_lines": { + "amount_joiner": ", ", + "anchored": "Anchored account cash was {cash}.", + "anchored_with_weight": "Anchored account cash was {cash}, {weight} of the account.", + "by_currency": "Anchored account cash by original currency: {amounts}." }, - "rule_targets": { - "line": "What this rule would catch this period: {items}", - "count_unit": "x", - "more_suffix": ", and {n} more" + "condition_basis": { + "stem": "You set this: \"{criterion}\". What is being measured may have changed: {note}. What should happen to it?" }, - "rule_tradeoff": { - "sizing_vs_proven_edge": "The cap limits what a single position can cost you; it does not contradict the stock-picking edge.", - "sizing_vs_hypothesis": "The cap limits what a single position can cost you, and the sector call is still unverified — not something to back with a bigger single bet." + "condition_basis_choices": { + "keep": "Leave it as it is", + "revise_metric": "Measure something else", + "revise_threshold": "Keep the measure, move the line", + "skip": "Leave it for now" }, - "condition_state": { - "already_met": "This line is already crossed today, so it is a decision to make now rather than a tripwire to watch.", - "unmapped_no_threshold": "This one is recorded in your words; there is no measurable line in it, so it cannot be checked for you.", - "unmapped_no_baseline": "This one is recorded, but the figure behind it could not be found, so it is not being watched yet.", - "unmapped_no_adjudicator": "This one is recorded as a yes-or-no call, and it stays yours to make rather than something measured for you.", - "event_watch_started": "This one is a yes-or-no call: it is being watched from now on, and when something turns up you get asked — the call stays yours." + "condition_basis_choices_descriptions": { + "keep": "The change does not affect what you were tracking.", + "revise_metric": "That quantity no longer captures what you meant — write the condition again.", + "revise_threshold": "The same quantity is still the right one; the number should change.", + "skip": "Leave it unanswered; the condition stands as written." }, "condition_check": { - "then_now": "Last time you committed: \"{rule}\" — it was {then} when you set it, {now} now.", - "then_now_no_baseline": "Last time you committed: \"{rule}\" — nothing was on record when you set it; it is {now} now.", - "then_now_event": "Last time you committed: \"{rule}\" — you started watching for it then. Latest: {now}.", - "verdict_met": "That is past your line.", - "verdict_near_line": "That is close to your line.", - "verdict_not_met": "That is still clear of your line.", - "verdict_unknown": "There is no verdict on it this period.", + "blind": "{criterion} — not checked this period, so it is currently blind.", + "blind_with_reason": "{criterion} — not checked this period ({reason}), so it is currently blind.", "fact": "{criterion} — {value} ({source}, as of {as_of}).", - "fact_no_source": "{criterion} — {value}.", "fact_looks_wrong": "If a figure there looks wrong, say so — the basis can be re-checked.", - "thesis_guard": "This is what you said would break your {ticker} thesis.", - "thesis_retired": "{criterion} — no longer checked from here on: you are fully out of {ticker}, so the thesis it was guarding is closed.", + "fact_no_source": "{criterion} — {value}.", + "note_basis_open": "What this measures may have changed ({note}), and that is still unsettled; I will ask about it next review.", "note_deferred": "That is past your line; I will ask you about it next review.", "note_deferred_event": "That may be the thing you were watching for; I will ask you about it next review.", "note_unanswered": "That is past your line and you have not said either way; I will ask again next review.", "note_unanswered_event": "That may be the thing you were watching for and you have not said either way; I will ask again next review.", - "note_basis_open": "What this measures may have changed ({note}), and that is still unsettled; I will ask about it next review.", - "blind": "{criterion} — not checked this period, so it is currently blind.", - "blind_with_reason": "{criterion} — not checked this period ({reason}), so it is currently blind.", "summary_checked": "Checked {checked} of {total} conditions this period.", "summary_open": "Open concerns coming back next review: {deferred}.", - "summary_trimmed": "This card shows {shown} of {describable} readings; the rest are in your record." + "summary_trimmed": "This card shows {shown} of {describable} readings; the rest are in your record.", + "then_now": "Last time you committed: \"{rule}\" — it was {then} when you set it, {now} now.", + "then_now_event": "Last time you committed: \"{rule}\" — you started watching for it then. Latest: {now}.", + "then_now_no_baseline": "Last time you committed: \"{rule}\" — nothing was on record when you set it; it is {now} now.", + "thesis_guard": "This is what you said would break your {ticker} thesis.", + "thesis_retired": "{criterion} — no longer checked from here on: you are fully out of {ticker}, so the thesis it was guarding is closed.", + "verdict_met": "That is past your line.", + "verdict_near_line": "That is close to your line.", + "verdict_not_met": "That is still clear of your line.", + "verdict_unknown": "There is no verdict on it this period." }, "condition_crossing": { + "event": "You set this: \"{criterion}\". This period: {evidence}. Did it happen?", "met": "You set this: \"{criterion}\". This period: {evidence}. Does that count as crossed?", - "near_line": "You set this: \"{criterion}\". This period: {evidence} — close to the line. Does that count as crossed?", - "event": "You set this: \"{criterion}\". This period: {evidence}. Did it happen?" + "near_line": "You set this: \"{criterion}\". This period: {evidence} — close to the line. Does that count as crossed?" }, "condition_crossing_choices": { "confirmed": "Yes, treat it as crossed", - "overridden": "No, do not treat it as crossed", - "yes": "Yes, it happened", "no": "No, it did not", - "skip": "Leave it for now" + "overridden": "No, do not treat it as crossed", + "skip": "Leave it for now", + "yes": "Yes, it happened" }, "condition_crossing_choices_descriptions": { "confirmed": "The reading stands, and the rule you wrote applies.", - "overridden": "The reading is measuring something other than what you meant — say why.", - "yes": "The thing you were watching for has happened.", "no": "It has not happened; keep watching.", - "skip": "Leave it unanswered; the reading is recorded either way." - }, - "condition_basis": { - "stem": "You set this: \"{criterion}\". What is being measured may have changed: {note}. What should happen to it?" + "overridden": "The reading is measuring something other than what you meant — say why.", + "skip": "Leave it unanswered; the reading is recorded either way.", + "yes": "The thing you were watching for has happened." }, - "condition_basis_choices": { - "revise_threshold": "Keep the measure, move the line", - "revise_metric": "Measure something else", - "keep": "Leave it as it is", - "skip": "Leave it for now" + "condition_state": { + "already_met": "This line is already crossed today, so it is a decision to make now rather than a tripwire to watch.", + "event_watch_started": "This one is a yes-or-no call: it is being watched from now on, and when something turns up you get asked — the call stays yours.", + "unmapped_no_adjudicator": "This one is recorded as a yes-or-no call, and it stays yours to make rather than something measured for you.", + "unmapped_no_baseline": "This one is recorded, but the figure behind it could not be found, so it is not being watched yet.", + "unmapped_no_threshold": "This one is recorded in your words; there is no measurable line in it, so it cannot be checked for you." }, - "condition_basis_choices_descriptions": { - "revise_threshold": "The same quantity is still the right one; the number should change.", - "revise_metric": "That quantity no longer captures what you meant — write the condition again.", - "keep": "The change does not affect what you were tracking.", - "skip": "Leave it unanswered; the condition stands as written." + "currency_note": { + "cached": "Display conversion uses the FX rate cached at the prior review.", + "cached_dated": "Display conversion uses the FX rate cached at the prior review ({when}).", + "no_rate": "No reliable {currency} display rate was available; amounts remain in original currencies.", + "portfolio_fx_gap": "At least one held-currency FX rate was unavailable; amounts remain in original currencies." }, - "patterns_panel": { - "label": "A pattern, not yet judged (no answer needed)", - "sold_winner_early": "{early} of the {total} positions you sold kept rising afterwards, most clearly {tickers}." + "decision_entries": { + "line": "{ticker}: {label}. The decision and its evidence boundary were saved for the next review.", + "ticker_default": "position" }, - "exit_consistency": { - "question": "Was that a consistent exit rule, an emotional reaction, or an external constraint?" + "demo_badge": "Demo data and rehearsal only; not your trades and not written to formal coach memory", + "dimensions": { + "alpha_beta": "benchmark attribution", + "averaging_down": "averaging-down discipline", + "diversification": "portfolio diversification", + "entry_style": "entry style", + "exit_discipline": "exit discipline", + "holding_period": "holding-horizon consistency", + "position_sizing": "position sizing" }, - "add_choices": { - "planned_tranche": "A pre-planned tranche", - "new_evidence": "New testable evidence", - "valuation_change": "The valuation or odds changed", - "price_only": "Only the price fell / lower the cost basis", - "skip": "Skip for now" + "due_choices": { + "falsified": "Wrong call", + "modified": "Partly right", + "skip": "Skip", + "still_valid": "Still valid" }, - "headline_motive_choices": { - "deliberate_plan": "Deliberate plan", - "emotional_reaction": "Emotional reaction", - "external_constraint": "External constraint" + "due_descriptions": { + "falsified": "The reason was wrong; record it as a lesson.", + "modified": "The reason was partly right and needs an adjustment.", + "skip": "Not saved as answered; the same checkpoint returns next review.", + "still_valid": "The reason holds; selling early can still be discipline." }, - "initial_thesis_choices": { - "planned_entry": "I had a planned thesis", - "momentum_follow": "I chased the momentum", - "external_call": "Someone recommended it", - "no_clear_thesis": "No clear thesis", - "skip": "Skip for now" + "etf_classification": { + "allocation": "Diversified allocation ETFs were separated from single-name concentration: {list}.", + "concentrated": "Sector, thematic, or leveraged ETFs remained concentration risk: {list}." }, "exit_choices": { "full": { - "price_target": "The target was reached", - "thesis_broken": "The original thesis failed", - "swap": "Reallocated to a better opportunity", "anxiety": "Locked in the gain", "other": "Another reason", - "skip": "Skip for now" + "price_target": "The target was reached", + "skip": "Skip for now", + "swap": "Reallocated to a better opportunity", + "thesis_broken": "The original thesis failed" }, "reduce": { - "price_target": "The planned reduction point was reached", - "thesis_broken": "Confidence weakened", - "swap": "Reallocated to a better opportunity", "anxiety": "Locked in part of the gain", "other": "Another reason", - "skip": "Skip for now" + "price_target": "The planned reduction point was reached", + "skip": "Skip for now", + "swap": "Reallocated to a better opportunity", + "thesis_broken": "Confidence weakened" } }, - "honesty": { - "snapshot_scope": "Transaction-history dimensions — averaging down, exit discipline, holding behavior, win rate, payoff, alpha, and historical motives — are out of scope for this position-snapshot review.", - "alpha_credibility": "The alpha sample or statistical strength is insufficient; it is not evidence of durable skill.", - "sector_attribution": "Some positions lack sector benchmarks, so allocation-versus-selection attribution is incomplete.", - "unclassified_drivers": "Some positions are unclassified, so portfolio concentration may be understated.", - "price_source": "Current prices did not come from the engine's own retrieval this period; they were either supplied from a named external source and date, or unavailable, and the card says which.", - "price_plausibility": "A supplied closing price differs sharply from that ticker's own last recorded trade price; the source should be re-verified before the figure is trusted.", - "unrealized_coverage": "Some positions lack current prices, so unrealized P&L is incomplete.", - "sizing_coverage": "Some positions have neither a live price nor a cost basis, so position sizing and the too-heavy check cover only the positions that could be valued.", - "orphan_sells": "Some exits lack a known entry; their realized P&L was excluded.", - "unpriced_exits": "Some positions left the record with no sale price, so those exits are excluded from win rate, payoff, and exit-discipline figures.", - "currency_mix": "The portfolio spans currencies; aggregate figures use a common currency and some FX may be approximate.", - "cash_reliability": "Cash lacks complete anchors, limiting account-level interpretation.", - "acct_perf_basis": "Account performance rests on an approximate basis — partial anchors, cost-line pricing, FX approximation, trade cash amounts estimated from quantity times price, or a period with no deposit or withdrawal on record — and remains uncertain.", - "accounting_reconciliation": "The imported transactions did not reproduce the snapshot-anchored current holdings, so current sizing, diversification, and unrealized P&L were not scored.", - "snapshot_reconciliation": "This declared snapshot was compared with the ledger-derived holdings; a match was marked reconciled, and any difference was recorded as an explicit adjustment with the newer declaration as the accounting anchor, without guessing why the numbers differ.", - "etf_metadata": "ETF expense-ratio or tracking-error data is incomplete; missing values were not treated as zero.", - "prior_commitment_breach": "This period already recorded a breach of that same rule, before this review closed the books on it." - }, - "instrument_tags": { - "suspected_averaging_down_losing": "likely averaging a loser: {n_adds} adds only while losing, now down {cur_abs_pct}{price_note} (confirm the thesis)", - "suspected_averaging_down_recovered": "likely averaged a loser, now green: {n_adds} adds only while losing — the recovery looks like luck, not discipline", - "adds_pending_confirmation": "{n_adds} adds to confirm: planned DCA or averaging a loser — your call", - "suspected_dca": "likely DCA: {n_adds} buys up and down, not averaging a loser", - "deep_underwater": "down {cur_abs_pct}{price_note} and still held without a decision", - "too_heavy": "too heavy: {wpct_pct} of the portfolio", - "disciplined_hold": "disciplined hold: {cur_signed_pct}{price_note}", - "roughly_neutral": "roughly neutral" + "exit_consistency": { + "question": "Was that a consistent exit rule, an emotional reaction, or an external constraint?" }, - "stress_test": { - "labels": { - "ai_thematic": "AI theme stocks (across sectors)", - "sector": "the {sector} sector", - "single_ticker": "a single name ({ticker})" - }, - "line": "Your exposure to {label} is about {exposure} ({pct} of the portfolio); a 30% drawdown would mean −{drop30} on paper, a 50% drawdown −{drop50} — could you sit through that?" + "exit_descriptions": { + "anxiety": "The main motive was protecting gains from a possible reversal.", + "other": "None of these fit; save a short explanation in your own words.", + "price_target": "A target or planned reduction condition was reached.", + "skip": "Save this as skipped so this exit's reason is not asked again.", + "swap": "Capital was reallocated to another position or use.", + "thesis_broken": "New facts broke or weakened the original thesis." }, - "prescription_kinds": { - "amplify": "Amplify the edge", - "amplify_hypothesis": "Amplify (a hypothesis, still unverified)", - "selection_inconclusive": "Stock picking: not enough data to judge", - "outsource": "Outsource the weak side (gradually)", - "cut_loss": "Cut the leak" + "exit_entries": { + "action_full": "exit", + "action_partial": "reduction", + "line": "{ticker}: you recorded the {action} reason as “{label}”. This preserves the reason at the time; it does not judge the outcome yet.", + "ticker_default": "position" }, - "prescription_texts": { - "amplify_hypothesis": "Of your {excess_pp}pp lead over the benchmark, {alloc_pp}pp came from being in the right theme. That is still a hypothesis rather than proven skill — a right call on AI can simply be standing in the wind, and only the next few calls can tell.", - "selection_inconclusive": "Within sectors, stock selection contributed {sel_pp}pp (descriptive; that number stands). But {t_note}, so the data cannot yet separate skill from luck.", - "selection_inconclusive_t_wide": "the alpha 95% interval is still too wide (t={t})", - "selection_inconclusive_t_unstable": "the alpha statistic is still unstable", - "outsource_selection": "Net of theme exposure, your in-sector stock selection contributed {sel_pp}pp and the shortfall is statistically solid. That does not mean giving up stock picking — part of the money can sit in a passive base with stock picks as the satellite. (A process observation, not a ticker recommendation.)", - "amplify_selection_edge": "Net of theme tailwinds, your in-sector stock selection still contributed {sel_pp}pp with statistically significant alpha — a real edge.", - "cut_averaging_down": "Adding to losing positions {count} times is the biggest pure leak in this record — the first behavior to remove.", - "cut_oversize": "Your largest position {ticker} holds {max_pct_pct} of the portfolio — a single oversized bet." + "exit_followup": { + "action_full": "full exit", + "action_reduce": "reduction", + "as_of": " as of {as_of}", + "backlog": "Historical exit backlog: {count} unresolved exits ({full} full, {reduce} reductions)", + "backlog_priced": " Across {priced} price-covered exits, the average post-exit move was {avg}; {rose} later rose.", + "backlog_span": " from {first} to {last}", + "backlog_top": "; most frequent: {top}", + "check": "{ticker}, {checkpoint}-day check: {label}.", + "focus": "Backlog focus: {ticker}, {action} on {exit_date}.", + "focus_idle": " Proceeds stayed idle while the original moved {orig}{when}.", + "focus_needs_prices": " No frozen-price comparison for {missing}{when}.", + "focus_orig": " The original moved {orig}{when}.", + "focus_swap": " The original moved {orig}{when}; the replacement moved {swap}; swap net {net}.", + "frozen_lead": " Using prices frozen on {as_of},", + "frozen_lead_absent": "", + "frozen_note": " using prices frozen on {as_of}", + "idle": " Proceeds stayed idle while the original moved {orig}{when}.", + "needs_prices": " Current prices are missing for {missing}{when}, so no outcome comparison was made.", + "note": " Note: \"{note}\".", + "position_fallback": "position", + "replacement_fallback": "the replacement", + "sentence_end": ".", + "swap": "{lead} the original moved {orig}; {swaps} moved {swap}; swap net {net}." }, - "due_choices": { - "still_valid": "Still valid", - "modified": "Partly right", - "falsified": "Wrong call", + "footnote_label": "Data notes", + "generic_choices": { + "deliberate_plan": "Deliberate plan", + "emotional_reaction": "Emotional reaction", + "external_constraint": "External constraint", "skip": "Skip" }, - "rule_breach_choices": { - "keep_tracking": "I did not keep it", - "revise_rule": "The rule needs revision", - "exception": "This was a justified exception" + "generic_descriptions": { + "deliberate_plan": "The action followed a rule set before the trade.", + "emotional_reaction": "Fear, regret, or urgency drove the action.", + "external_constraint": "Liquidity, tax, or another constraint drove it.", + "skip": "Leave the motive unresolved for now." }, - "rule_breach_decisions": { - "keep_tracking": "not kept; keep tracking", - "revise_rule": "marked for revision", - "exception": "recorded as a justified exception" + "headline_motive_choices": { + "deliberate_plan": "Deliberate plan", + "emotional_reaction": "Emotional reaction", + "external_constraint": "External constraint" + }, + "hole_lines": { + "averaging_down": "you added to losing positions {count} times ({tickers}); {breach} of those adds put the position over 25% of cost basis at the moment of the add", + "diversification": "you hold {n} names that look diversified, but AI-capex exposure is {ai_pct}, the largest sector ({max_sector}) is {max_sector_pct} and the top three are {top3} — one and the same driver", + "exit_forward": "of {n_rt} decision exits ({n_scored} with forward data, {n_trunc} truncated), {early_rate} were higher {n_fwd} days later, climbing {avg_forgone} on average{winner_early}", + "exit_holding": "winners held {hold_win} days / losers held {hold_lose} days (disposition gap {disp_gap})", + "exit_winner_early": "; {winner_early} of the winners you sold kept climbing (sold too early)", + "holding_base": "your holding periods run {min}–{max} days, median {median_hold} days", + "holding_consistent": "{base} (median {median_hold} days = your main framework; frameworks within a name are broadly consistent)", + "holding_inconsistent": "{base}; {n_incon} of {n_multi} names were both day-traded and held long ({tickers}) — no consistent framework within the same name", + "holding_no_data": "no closed round trips yet, so holding-time statistics are still pending (positions bought and not yet sold are not counted)", + "holding_same_day": "all completed trades were same-day; no overnight holding", + "joiner": "; ", + "position_sizing": "your largest single position {max_ticker} was {max_pct}, against a {avg_pct} average across the rest", + "ticker_joiner": ", " + }, + "honesty": { + "accounting_reconciliation": "The imported transactions did not reproduce the snapshot-anchored current holdings, so current sizing, diversification, and unrealized P&L were not scored.", + "acct_perf_basis": "Account performance rests on an approximate basis — partial anchors, cost-line pricing, FX approximation, trade cash amounts estimated from quantity times price, or a period with no deposit or withdrawal on record — and remains uncertain.", + "alpha_credibility": "The alpha sample or statistical strength is insufficient; it is not evidence of durable skill.", + "cash_reliability": "Cash lacks complete anchors, limiting account-level interpretation.", + "currency_mix": "The portfolio spans currencies; aggregate figures use a common currency and some FX may be approximate.", + "etf_metadata": "ETF expense-ratio or tracking-error data is incomplete; missing values were not treated as zero.", + "orphan_sells": "Some exits lack a known entry; their realized P&L was excluded.", + "price_plausibility": "A supplied closing price differs sharply from that ticker's own last recorded trade price; the source should be re-verified before the figure is trusted.", + "price_source": "Current prices did not come from the engine's own retrieval this period; they were either supplied from a named external source and date, or unavailable, and the card says which.", + "prior_commitment_breach": "This period already recorded a breach of that same rule, before this review closed the books on it.", + "sector_attribution": "Some positions lack sector benchmarks, so allocation-versus-selection attribution is incomplete.", + "sizing_coverage": "Some positions have neither a live price nor a cost basis, so position sizing and the too-heavy check cover only the positions that could be valued.", + "snapshot_reconciliation": "This declared snapshot was compared with the ledger-derived holdings; a match was marked reconciled, and any difference was recorded as an explicit adjustment with the newer declaration as the accounting anchor, without guessing why the numbers differ.", + "snapshot_scope": "Transaction-history dimensions — averaging down, exit discipline, holding behavior, win rate, payoff, alpha, and historical motives — are out of scope for this position-snapshot review.", + "unclassified_drivers": "Some positions are unclassified, so portfolio concentration may be understated.", + "unpriced_exits": "Some positions left the record with no sale price, so those exits are excluded from win rate, payoff, and exit-discipline figures.", + "unrealized_coverage": "Some positions lack current prices, so unrealized P&L is incomplete." + }, + "horizon_entries": { + "exit_too_fast": "{ticker}: the {voice} thesis horizon was {horizon}, but it ended after {days} days; this is a timeline mismatch, not a verdict about the motive.", + "held_too_long": "{ticker}: the {voice} thesis horizon was {horizon}, but it is still open after {days} days; the horizon has drifted and needs clarification.", + "ticker_default": "Position", + "voice_inferred": "inferred", + "voice_recorded": "recorded" }, "horizons": { - "weeks": "weeks", "quarters": "quarters", + "weeks": "weeks", "years": "years" }, - "problem_keys": { - "avgdown_breach": "Averaging-down boundary", - "sell_winner_early": "Winner exit opportunity cost", - "oversize": "Oversized position", - "concentration": "Shared-driver concentration", - "hold_inconsistency": "Holding-horizon inconsistency", - "exit_anxiety": "Anxiety-driven exit", - "fomo_entry": "FOMO entry", - "horizon_break": "Thesis-horizon break" + "initial_thesis_choices": { + "external_call": "Someone recommended it", + "momentum_follow": "I chased the momentum", + "no_clear_thesis": "No clear thesis", + "planned_entry": "I had a planned thesis", + "skip": "Skip for now" }, - "trends": { - "worse": "worsening", - "flat": "flat", - "better": "improving" + "initial_thesis_descriptions": { + "external_call": "A KOL, friend, or research recommendation drove it.", + "momentum_follow": "You chased price momentum or FOMO.", + "no_clear_thesis": "You are holding without a clear thesis.", + "planned_entry": "You had an explicit thesis before entering.", + "skip": "Leave the entry motive unresolved for now." + }, + "instrument_tags": { + "adds_pending_confirmation": "{n_adds} adds to confirm: planned DCA or averaging a loser — your call", + "deep_underwater": "down {cur_abs_pct}{price_note} and still held without a decision", + "disciplined_hold": "disciplined hold: {cur_signed_pct}{price_note}", + "roughly_neutral": "roughly neutral", + "suspected_averaging_down_losing": "likely averaging a loser: {n_adds} adds only while losing, now down {cur_abs_pct}{price_note} (confirm the thesis)", + "suspected_averaging_down_recovered": "likely averaged a loser, now green: {n_adds} adds only while losing — the recovery looks like luck, not discipline", + "suspected_dca": "likely DCA: {n_adds} buys up and down, not averaging a loser", + "too_heavy": "too heavy: {wpct_pct} of the portfolio" + }, + "kpi": { + "alpha": "Annualized alpha", + "alpha_unreliable": "not yet statistically credible", + "curve": "Path this period", + "excess": "Benchmark excess", + "excess_sub": "β {beta}", + "payoff": "Realized payoff ratio", + "payoff_sub": "{win} win vs {loss} loss", + "pnl": "Total P&L", + "pnl_sub": "{realized}, {unrealized} unrealized", + "spark_caption": "peak {peak} · trough {trough}" + }, + "language": "en", + "markdown_summary": { + "next": "Next rule", + "risk": "Key risk" + }, + "motive_entries": { + "context": "Engine context: {fact}.", + "recorded": "Motive recorded for {subject}: {label}.", + "saved": "This recorded choice was saved for a later review.", + "subject_default": "highlighted behavior", + "terminator": "." + }, + "patterns_panel": { + "label": "A pattern, not yet judged (no answer needed)", + "sold_winner_early": "{early} of the {total} positions you sold kept rising afterwards, most clearly {tickers}." + }, + "payoff_lines": { + "drag_plain": "The largest realized drag was {ticker}; without it, the payoff ratio would have been {after}.", + "drag_with_amount": "The largest realized drag was {ticker} at {drag}; without it, the payoff ratio would have been {after}.", + "original_currency": "Realized payoff ratio was {payoff}; average gain/loss amounts remain in original currencies.", + "sub_wrap": " ({sub})" + }, + "period": { + "as_of": "As of {end}", + "span": "Review period {start} → {end}", + "vix": "VIX {value}" }, "pnl_lines": { "display": { - "total": "Total P&L was {total}: {realized} realized and {unrealized} unrealized.", "realized_only": "Realized P&L was {realized}; current unrealized P&L was not scored.", + "total": "Total P&L was {total}: {realized} realized and {unrealized} unrealized.", "unrealized_only": "Unrealized P&L was {unrealized}; realized P&L was unavailable." }, "original": { @@ -329,258 +400,200 @@ "unrealized_only": "{currency} unrealized P&L was {unrealized}." } }, - "alpha_interval": { - "scope_suffix": " for {market} holdings", - "negative_caveat": " (The interval includes a negative value, meaning this period's stock-picking edge is not yet statistically confirmed as a durable skill.)", - "lead": "Risk-adjusted alpha{scope} was {alpha}% annualized, with a 95% interval from {low}% to {high}%; the interval controls how strong the conclusion may be.", - "tile_sub": "95% interval {low}% to {high}%", - "below_unreliable": "* The annualized α is not yet statistically credible.", - "below_negative": "The annualized α interval includes a negative value, meaning this period's stock-picking edge is not yet statistically confirmed as a durable skill." - }, - "best_strength": { - "no_signal": "No positive behavior was strong enough to claim; keep attention on the largest leak.", - "exit_discipline": "you take profit without flinching: only {winner_early} of the winners you sold kept climbing afterwards, so you are not the kind who clings to a gain until it gives itself back", - "position_sizing": "single-position size is under control: even your heaviest name is only {max_pct}, so your net worth is not riding on one ticker", - "averaging_down": "you added down {count} times and still stayed inside your own position limit, never letting one name grow heavy on the way down{example}", - "averaging_down_example": " (for example {ticker})", - "diversification": "{n} names spread across different drivers, so you are not all-in on one story", - "holding_period": "your entries and exits keep a consistent rhythm: a median hold of {median_hold} days, not random buying and selling" + "prescription_kinds": { + "amplify": "Amplify the edge", + "amplify_hypothesis": "Amplify (a hypothesis, still unverified)", + "cut_loss": "Cut the leak", + "outsource": "Outsource the weak side (gradually)", + "selection_inconclusive": "Stock picking: not enough data to judge" }, - "reconciliation": { - "statement": "Last time you committed: \"{rule}\".", - "statement_with_metric": "Last time you committed: \"{rule}\" — the tracked number was {then} then, {now} now." + "prescription_texts": { + "amplify_hypothesis": "Of your {excess_pp}pp lead over the benchmark, {alloc_pp}pp came from being in the right theme. That is still a hypothesis rather than proven skill — a right call on AI can simply be standing in the wind, and only the next few calls can tell.", + "amplify_selection_edge": "Net of theme tailwinds, your in-sector stock selection still contributed {sel_pp}pp with statistically significant alpha — a real edge.", + "cut_averaging_down": "Adding to losing positions {count} times is the biggest pure leak in this record — the first behavior to remove.", + "cut_oversize": "Your largest position {ticker} holds {max_pct_pct} of the portfolio — a single oversized bet.", + "outsource_selection": "Net of theme exposure, your in-sector stock selection contributed {sel_pp}pp and the shortfall is statistically solid. That does not mean giving up stock picking — part of the money can sit in a passive base with stock picks as the satellite. (A process observation, not a ticker recommendation.)", + "selection_inconclusive": "Within sectors, stock selection contributed {sel_pp}pp (descriptive; that number stands). But {t_note}, so the data cannot yet separate skill from luck.", + "selection_inconclusive_t_unstable": "the alpha statistic is still unstable", + "selection_inconclusive_t_wide": "the alpha 95% interval is still too wide (t={t})" }, - "snapshot": { - "overview": { - "subject_with_count": "{positions} supplied positions", - "subject_generic": "the supplied positions", - "opening": "This is an opening portfolio check of {subject}.", - "opening_as_of": "This is an opening portfolio check of {subject} as of {as_of}.", - "valuation_market_value": "Structural weights use the supplied market-value basis.", - "valuation_cost": "Structural weights use the supplied cost basis.", - "valuation_unavailable": "No reliable valuation basis was available, so weight-based structure remains unscored.", - "missing_avg_cost": "Average cost was missing for: {tickers}.", - "missing_fx": "Reliable FX coverage was missing for: {currencies}." - }, - "strength": { - "weighted": "The supplied positions establish a structural baseline, and their weights are scored; missing inputs remain explicit rather than inferred.", - "baseline": "The supplied positions establish a structural baseline; weight-based strengths remain unscored." - }, - "holes": { - "leading_risk": "The leading structural risk in the available snapshot was {label}.", - "clean_structure": "This position snapshot did not flag concentration or diversification as a structural risk.", - "no_weights": "This opening check establishes a structural baseline without treating unavailable weights as low risk." - } + "private_badge": "Review card; stored locally only", + "problem_keys": { + "avgdown_breach": "Averaging-down boundary", + "concentration": "Shared-driver concentration", + "exit_anxiety": "Anxiety-driven exit", + "fomo_entry": "FOMO entry", + "hold_inconsistency": "Holding-horizon inconsistency", + "horizon_break": "Thesis-horizon break", + "oversize": "Oversized position", + "sell_winner_early": "Winner exit opportunity cost" }, "problems": { - "trend_line": "{name}: {recent} events in the recent window versus {prev} before ({trend}).", "breach_decision": "Rule \"{rule}\": {label}.", "breach_note": " Note: \"{note}\".", - "rule_held": "Rule \"{rule}\" was kept in the latest observable period." + "rule_held": "Rule \"{rule}\" was kept in the latest observable period.", + "trend_line": "{name}: {recent} events in the recent window versus {prev} before ({trend})." }, - "exit_followup": { - "position_fallback": "position", - "check": "{ticker}, {checkpoint}-day check: {label}.", - "note": " Note: \"{note}\".", - "as_of": " as of {as_of}", - "needs_prices": " Current prices are missing for {missing}{when}, so no outcome comparison was made.", - "replacement_fallback": "the replacement", - "frozen_lead": " Using prices frozen on {as_of},", - "frozen_lead_absent": "", - "swap": "{lead} the original moved {orig}; {swaps} moved {swap}; swap net {net}.", - "frozen_note": " using prices frozen on {as_of}", - "idle": " Proceeds stayed idle while the original moved {orig}{when}.", - "backlog": "Historical exit backlog: {count} unresolved exits ({full} full, {reduce} reductions)", - "backlog_span": " from {first} to {last}", - "backlog_top": "; most frequent: {top}", - "sentence_end": ".", - "backlog_priced": " Across {priced} price-covered exits, the average post-exit move was {avg}; {rose} later rose.", - "action_full": "full exit", - "action_reduce": "reduction", - "focus": "Backlog focus: {ticker}, {action} on {exit_date}.", - "focus_needs_prices": " No frozen-price comparison for {missing}{when}.", - "focus_swap": " The original moved {orig}{when}; the replacement moved {swap}; swap net {net}.", - "focus_idle": " Proceeds stayed idle while the original moved {orig}{when}.", - "focus_orig": " The original moved {orig}{when}." + "public_badge": "Shareable version; amounts, dates, tickers, and exact weights removed", + "public_band": { + "high": "high", + "low": "low", + "moderate": "moderate", + "very_high": "very high" }, - "account_perf": { - "holdings_only": "Holdings-only cumulative return was {value}.", - "account_base": "Account-level cumulative return was {value}", - "annualized_suffix": "; annualized return was {value}", - "cash_drag_suffix": "; the gap versus the holdings pillar, {value}, is explained by holding cash — an observation, not a verdict", - "terminator": "." + "public_custom_rule": "One self-authored process rule was committed; its wording stays on the review card.", + "public_mirror": { + "behavioral": "This review found {severity} behavioral pressure in {dim}.", + "behavioral_dim_default": "the leading diagnostic dimension", + "no_holes": "This review did not rank a leading behavior pattern from the available history.", + "snapshot_baseline": "This opening portfolio check establishes a structural baseline; transaction-history behavior remains unscored.", + "structural": "This opening portfolio check identified {dim} as the leading structural risk.", + "structural_dim_default": "portfolio structure", + "structural_with_severity": "This opening portfolio check identified {dim} as the leading structural risk, with {severity} pressure.", + "structure": "Diversified allocation ETFs were separated from single-name risk; focused ETFs remained concentration risk." }, - "currency_note": { - "cached_dated": "Display conversion uses the FX rate cached at the prior review ({when}).", - "cached": "Display conversion uses the FX rate cached at the prior review.", - "portfolio_fx_gap": "At least one held-currency FX rate was unavailable; amounts remain in original currencies.", - "no_rate": "No reliable {currency} display rate was available; amounts remain in original currencies." + "public_patterns": { + "alpha_beta": "The highlighted behavior concerned how benchmark-relative results were attributed between exposure and selection.", + "averaging_down": "The highlighted behavior concerned how additions to losing positions were bounded.", + "diversification": "The highlighted behavior concerned whether the portfolio represented distinct underlying risks.", + "entry_style": "The highlighted behavior concerned when entries occurred relative to recent price moves.", + "exit_discipline": "The highlighted behavior concerned how exit decisions were timed and evaluated.", + "holding_period": "The highlighted behavior concerned whether holding durations matched a consistent decision horizon.", + "holding_period_same_day": "All completed trades were same-day; no overnight holding.", + "position_sizing": "The highlighted behavior concerned how risk was distributed across position sizes." }, - "benchmark_line": { - "line": "{subject} beat {comparator} by {excess} pp{beta}.", - "subject_market": "{market} holdings", - "subject_default": "The holdings", - "comparator_default": "their market benchmark", - "beta_suffix": "; β {beta}" + "public_performance": { + "line": "{subject}: {excess} pp versus its market benchmark; β {beta}.", + "subject_default": "Portfolio" }, - "split_lines": { - "line": "{subject} {excess} pp excess split into {allocation} pp from market/sector allocation and {selection} pp from security selection.", - "subject_market": "{market}'s", - "subject_default": "The portfolio's" + "rationale_refresh": { + "stem": "On {stated_at} you said you hold {ticker} because: \"{statement}\". Is that still the reason?" }, - "decision_entries": { - "line": "{ticker}: {label}. The decision and its evidence boundary were saved for the next review.", - "ticker_default": "position" + "rationale_refresh_choices": { + "changed": "The reason has changed", + "same": "Still the same reason", + "skip": "Leave it for now" }, - "motive_entries": { - "subject_default": "highlighted behavior", - "context": "Engine context: {fact}.", - "recorded": "Motive recorded for {subject}: {label}.", - "saved": "This recorded choice was saved for a later review.", - "terminator": "." + "rationale_refresh_choices_descriptions": { + "changed": "Write the reason as it stands now. The old wording is kept, not replaced.", + "same": "Recorded as confirmed on today's date. Your original wording stays exactly as you wrote it.", + "skip": "Leave it unanswered; what you said before stays in force." }, - "exit_entries": { - "line": "{ticker}: you recorded the {action} reason as “{label}”. This preserves the reason at the time; it does not judge the outcome yet.", - "ticker_default": "position", - "action_full": "exit", - "action_partial": "reduction" + "reconciliation": { + "statement": "Last time you committed: \"{rule}\".", + "statement_with_metric": "Last time you committed: \"{rule}\" — the tracked number was {then} then, {now} now." }, "review_milestone": { "line": "When this review started, you already had {completed} completed {noun}.", - "noun_one": "review", - "noun_many": "reviews" + "noun_many": "reviews", + "noun_one": "review" }, - "horizon_entries": { - "ticker_default": "Position", - "voice_inferred": "inferred", - "voice_recorded": "recorded", - "exit_too_fast": "{ticker}: the {voice} thesis horizon was {horizon}, but it ended after {days} days; this is a timeline mismatch, not a verdict about the motive.", - "held_too_long": "{ticker}: the {voice} thesis horizon was {horizon}, but it is still open after {days} days; the horizon has drifted and needs clarification." + "rule_breach_choices": { + "exception": "This was a justified exception", + "keep_tracking": "I did not keep it", + "revise_rule": "The rule needs revision" }, - "public_band": { - "low": "low", - "moderate": "moderate", - "high": "high", - "very_high": "very high" + "rule_breach_decisions": { + "exception": "recorded as a justified exception", + "keep_tracking": "not kept; keep tracking", + "revise_rule": "marked for revision" }, - "public_performance": { - "line": "{subject}: {excess} pp versus its market benchmark; β {beta}.", - "subject_default": "Portfolio" + "rule_breach_descriptions": { + "exception": "This was a justified exception; record why in the note while keeping the event in history.", + "keep_tracking": "The rule still fits, but it was not kept; record it and keep tracking.", + "revise_rule": "The rule itself does not fit; note why it needs revision, then use the one final commitment for the replacement." }, - "payoff_lines": { - "sub_wrap": " ({sub})", - "original_currency": "Realized payoff ratio was {payoff}; average gain/loss amounts remain in original currencies.", - "drag_with_amount": "The largest realized drag was {ticker} at {drag}; without it, the payoff ratio would have been {after}.", - "drag_plain": "The largest realized drag was {ticker}; without it, the payoff ratio would have been {after}." + "rule_grounding": { + "averaging_down": "This period: you averaged down {count} times into losing positions, including {tickers}.", + "diversification": "This period: your top positions {tickers} together hold {pct}.", + "holding_period": "This period: {tickers} mixed quick exits and long holds within the same position.", + "position_sizing": "This period: your largest single position {ticker} is {pct} of the portfolio." }, - "cash_lines": { - "anchored_with_weight": "Anchored account cash was {cash}, {weight} of the account.", - "anchored": "Anchored account cash was {cash}.", - "by_currency": "Anchored account cash by original currency: {amounts}.", - "amount_joiner": ", " + "rule_targets": { + "count_unit": "x", + "line": "What this rule would catch this period: {items}", + "more_suffix": ", and {n} more" }, - "public_mirror": { - "structural_with_severity": "This opening portfolio check identified {dim} as the leading structural risk, with {severity} pressure.", - "structural": "This opening portfolio check identified {dim} as the leading structural risk.", - "structural_dim_default": "portfolio structure", - "snapshot_baseline": "This opening portfolio check establishes a structural baseline; transaction-history behavior remains unscored.", - "no_holes": "This review did not rank a leading behavior pattern from the available history.", - "behavioral": "This review found {severity} behavioral pressure in {dim}.", - "behavioral_dim_default": "the leading diagnostic dimension", - "structure": "Diversified allocation ETFs were separated from single-name risk; focused ETFs remained concentration risk." + "rule_tradeoff": { + "sizing_vs_hypothesis": "The cap limits what a single position can cost you, and the sector call is still unverified — not something to back with a bigger single bet.", + "sizing_vs_proven_edge": "The cap limits what a single position can cost you; it does not contradict the stock-picking edge." }, - "tag_values": { - "price_note": " (now {px} / cost {avg_cost})" + "rules": { + "averaging_down": "Before averaging down, write one piece of evidence you did not know at entry. If you cannot, do not add.", + "diversification": "Before adding a position, check whether it is still the same underlying driver. If that bet is already too large, do not add.", + "exit_discipline": "Before exiting, name the fact that completed or broke the thesis. Fear of giving back gains is not enough.", + "holding_period": "Label the trade as short-term, swing, or long-term at entry. Exit only for a reason from the same horizon.", + "position_sizing": "Cap any single position at {cap}. Trim if it goes over, and do not add." + }, + "sections": { + "etf": "ETF and portfolio structure", + "hole": "The biggest behavioral leak", + "performance": "Relative performance", + "rule": "Change only this next time", + "strength": "One thing you did well" }, "sectors": { "bonds": "bonds", - "crypto": "crypto", - "regional_etf": "regional ETFs", - "semiconductors": "semiconductors", - "commodities": "commodities", "broad_market_etf": "broad-market ETFs", - "unclassified": "unclassified", + "commodities": "commodities", "consumer": "consumer", + "crypto": "crypto", + "datacenter_power": "data-center power", "drones_defense": "drones and defense", + "ev_ai": "EV and AI", + "fintech": "fintech", "rare_earth_materials": "rare-earth materials", - "datacenter_power": "data-center power", + "regional_etf": "regional ETFs", + "semiconductors": "semiconductors", "software_cloud": "software and cloud", - "fintech": "fintech", "telecom": "telecom", - "ev_ai": "EV and AI" + "unclassified": "unclassified" }, - "hole_lines": { - "joiner": "; ", - "ticker_joiner": ", ", - "exit_forward": "of {n_rt} decision exits ({n_scored} with forward data, {n_trunc} truncated), {early_rate} were higher {n_fwd} days later, climbing {avg_forgone} on average{winner_early}", - "exit_winner_early": "; {winner_early} of the winners you sold kept climbing (sold too early)", - "exit_holding": "winners held {hold_win} days / losers held {hold_lose} days (disposition gap {disp_gap})", - "position_sizing": "your largest single position {max_ticker} was {max_pct}, against a {avg_pct} average across the rest", - "diversification": "you hold {n} names that look diversified, but AI-capex exposure is {ai_pct}, the largest sector ({max_sector}) is {max_sector_pct} and the top three are {top3} — one and the same driver", - "holding_no_data": "no closed round trips yet, so holding-time statistics are still pending (positions bought and not yet sold are not counted)", - "holding_same_day": "all completed trades were same-day; no overnight holding", - "holding_base": "your holding periods run {min}–{max} days, median {median_hold} days", - "holding_inconsistent": "{base}; {n_incon} of {n_multi} names were both day-traded and held long ({tickers}) — no consistent framework within the same name", - "holding_consistent": "{base} (median {median_hold} days = your main framework; frameworks within a name are broadly consistent)", - "averaging_down": "you added to losing positions {count} times ({tickers}); {breach} of those adds put the position over 25% of cost basis at the moment of the add" - }, - "add_descriptions": { - "planned_tranche": "The tranche schedule existed before the price move.", - "new_evidence": "Requires a claim and source that a later review can test.", - "valuation_change": "The thesis is unchanged, but price changed the odds or margin of safety.", - "price_only": "No new fact; the main motive was lowering the cost basis or getting back to even.", - "skip": "Leave the motive unclassified for now." - }, - "generic_choices": { - "deliberate_plan": "Deliberate plan", - "emotional_reaction": "Emotional reaction", - "external_constraint": "External constraint", - "skip": "Skip" - }, - "generic_descriptions": { - "deliberate_plan": "The action followed a rule set before the trade.", - "emotional_reaction": "Fear, regret, or urgency drove the action.", - "external_constraint": "Liquidity, tax, or another constraint drove it.", - "skip": "Leave the motive unresolved for now." - }, - "initial_thesis_descriptions": { - "planned_entry": "You had an explicit thesis before entering.", - "momentum_follow": "You chased price momentum or FOMO.", - "external_call": "A KOL, friend, or research recommendation drove it.", - "no_clear_thesis": "You are holding without a clear thesis.", - "skip": "Leave the entry motive unresolved for now." - }, - "exit_descriptions": { - "price_target": "A target or planned reduction condition was reached.", - "thesis_broken": "New facts broke or weakened the original thesis.", - "swap": "Capital was reallocated to another position or use.", - "anxiety": "The main motive was protecting gains from a possible reversal.", - "other": "None of these fit; save a short explanation in your own words.", - "skip": "Save this as skipped so this exit's reason is not asked again." - }, - "due_descriptions": { - "still_valid": "The reason holds; selling early can still be discipline.", - "modified": "The reason was partly right and needs an adjustment.", - "falsified": "The reason was wrong; record it as a lesson.", - "skip": "Not saved as answered; the same checkpoint returns next review." + "snapshot": { + "holes": { + "clean_structure": "This position snapshot did not flag concentration or diversification as a structural risk.", + "leading_risk": "The leading structural risk in the available snapshot was {label}.", + "no_weights": "This opening check establishes a structural baseline without treating unavailable weights as low risk." + }, + "overview": { + "missing_avg_cost": "Average cost was missing for: {tickers}.", + "missing_fx": "Reliable FX coverage was missing for: {currencies}.", + "opening": "This is an opening portfolio check of {subject}.", + "opening_as_of": "This is an opening portfolio check of {subject} as of {as_of}.", + "subject_generic": "the supplied positions", + "subject_with_count": "{positions} supplied positions", + "valuation_cost": "Structural weights use the supplied cost basis.", + "valuation_market_value": "Structural weights use the supplied market-value basis.", + "valuation_unavailable": "No reliable valuation basis was available, so weight-based structure remains unscored." + }, + "strength": { + "baseline": "The supplied positions establish a structural baseline; weight-based strengths remain unscored.", + "weighted": "The supplied positions establish a structural baseline, and their weights are scored; missing inputs remain explicit rather than inferred." + } }, - "rule_breach_descriptions": { - "keep_tracking": "The rule still fits, but it was not kept; record it and keep tracking.", - "revise_rule": "The rule itself does not fit; note why it needs revision, then use the one final commitment for the replacement.", - "exception": "This was a justified exception; record why in the note while keeping the event in history." + "snapshot_hole": "The leading structural risk", + "snapshot_numbers": "Opening portfolio structure", + "snapshot_strength": "What this snapshot establishes", + "split_lines": { + "line": "{subject} {excess} pp excess split into {allocation} pp from market/sector allocation and {selection} pp from security selection.", + "subject_default": "The portfolio's", + "subject_market": "{market}'s" }, - "asked_because": { - "pnl_impact": "it is the position with the largest P&L impact this week", - "position_cost": "it is your largest position by cost this week", - "exit_notional": "it is one of your largest recent exits by amount" + "stress_test": { + "labels": { + "ai_thematic": "AI theme stocks (across sectors)", + "sector": "the {sector} sector", + "single_ticker": "a single name ({ticker})" + }, + "line": "Your exposure to {label} is about {exposure} ({pct} of the portfolio); a 30% drawdown would mean −{drop30} on paper, a 50% drawdown −{drop50} — could you sit through that?" }, - "breach_evidence": { - "none": "a recorded event", - "more": "; and {extra} more", - "joiner": "; " + "tag_values": { + "price_note": " (now {px} / cost {avg_cost})" }, - "candidate_comparison": { - "line": "{top} scored higher than {others} on this period's severity ranking -- that reflects which pattern showed up more strongly this period, not which rule is the right fit for you.", - "joiner": " and " + "title": "Review Card", + "trends": { + "better": "improving", + "flat": "flat", + "worse": "worsening" } } diff --git a/skills/fomo-kernel/copy/zh-CN.json b/skills/fomo-kernel/copy/zh-CN.json index da05602a..7f5c40a5 100644 --- a/skills/fomo-kernel/copy/zh-CN.json +++ b/skills/fomo-kernel/copy/zh-CN.json @@ -1,260 +1,331 @@ { - "language": "zh-CN", - "title": "复盘卡", - "private_badge": "复盘卡,只留在本机", - "public_badge": "可分享版,已移除金额、日期、标的与精确部位", - "public_custom_rule": "已承诺一条自订流程规矩;内容只留在复盘卡。", - "demo_badge": "示范资料/演练,不是你的真实交易,也不写入正式教练记忆", - "markdown_summary": { - "risk": "主要风险", - "next": "下次规则" + "account_gate": { + "cash_residual": "更新那一段的现金部位,就能看到含现金的账户级报酬。", + "chain_unavailable": "这个区间没有任何一天算得出可用的账户净值可以串成报酬。", + "default": "补齐现金锚点,就能看到含现金的账户级报酬。", + "mixed_trade_footprint": "交易纪录里只有一部分带券商的现金金额,两种口径并不成同一条现金史。", + "negative_cash_rollback": "补上还没有锚点的那个币别,就能看到含现金的账户级报酬。", + "no_cash_anchor": "补一个目前的现金余额,就能看到含现金的账户级报酬。" }, - "snapshot_numbers": "第一次持仓结构", - "snapshot_strength": "这份快照建立了什么", - "snapshot_hole": "主要结构风险", - "sections": { - "performance": "相对绩效", - "strength": "你做对的一件事", - "hole": "最大的行为漏洞", - "etf": "ETF 与组合结构", - "rule": "下次只改这一件" + "account_perf": { + "account_base": "账户级累积报酬为 {value}", + "annualized_suffix": ",年化报酬 {value}", + "cash_drag_suffix": ";与仅计持仓的差距 {value} 来自持有现金——这是观察,不是对错判定", + "holdings_only": "仅计持仓的累积报酬为 {value}。", + "terminator": "。" }, - "blocks": { - "performance": "这期的绩效", - "trades": "关键交易 · 按金额排序", - "risks": "风险与问题", - "next": "下一步", - "summary": "总结" + "add_choices": { + "new_evidence": "有新的可验证证据", + "planned_tranche": "事先规划的分批", + "price_only": "只有价格下跌/想摊低成本", + "skip": "先跳过", + "valuation_change": "估值或赔率变了" }, - "period": { - "span": "复盘区间 {start} → {end}", - "as_of": "截至 {end}", - "vix": "VIX {value}" + "add_descriptions": { + "new_evidence": "必须补 claim 与 source,之后能回头验证。", + "planned_tranche": "进场前已定好节奏,价格下跌不是新增理由。", + "price_only": "没有新事实,主要是想摊低成本或等回本。", + "skip": "先不定性,卡上只标未确认。", + "valuation_change": "判断没变,但价格让赔率或安全边际改变。" + }, + "alpha_interval": { + "below_negative": "年化 α 的区间包含负值,代表这段期间的选股优势在统计上还不能视为稳定能力。", + "below_unreliable": "* 年化 α 统计上还不可信。", + "lead": "风险调整后 alpha{scope} 年化 {alpha}%,95% 区间为 {low}% 到 {high}%;定论强度以这个区间为准。", + "negative_caveat": "(区间包含负值,代表这段期间的选股优势在统计上还不能视为稳定能力)", + "scope_suffix": "({market} 部位)", + "tile_sub": "95% 区间 {low}%~{high}%" + }, + "asked_because": { + "exit_notional": "它是你近期金额最大的出场之一", + "pnl_impact": "它是你本周影响损益最大的部位", + "position_cost": "它是你本周成本最大的部位" + }, + "benchmark_line": { + "beta_suffix": ";β {beta}", + "comparator_default": "市场大盘", + "line": "{subject}对 {comparator} 的超额报酬 {excess} 个百分点{beta}。", + "subject_default": "持仓", + "subject_market": "{market} 部位" + }, + "best_strength": { + "averaging_down": "你往下加码 {count} 次,却都守在自己的部位上限内、没让任何一档越摊越重{example}", + "averaging_down_example": "(例:{ticker})", + "diversification": "{n} 档分布在不同驱动因子,没有全押在同一个故事上", + "exit_discipline": "该获利了结时你不手软:卖掉的赚钱单只有 {winner_early} 事后继续涨,代表你不会“抱着赚钱的舍不得卖、结果回吐”", + "holding_period": "进出有一致的节奏:中位持有 {median_hold} 天,不是随机乱买乱卖", + "no_signal": "这期没有足够强的正向讯号;先把注意力留给最大的洞。", + "position_sizing": "单笔部位有控制:押最重的一档也只占 {max_pct},没把身家压在一档上" }, "block_missing": { "absolute_pnl": "本期算不出账面损益:缺交易或估值基础。", "annualized": "本期算不出年化报酬:现金流锚点不完整。", "annualized_prices": "本期算不出年化报酬:现价取不到,整体持仓没有可用的估值基础。", - "vs_market": "本期无法比对大盘:缺可用的基准序列。", - "vs_market_prices": "本期无法比对大盘:基准指数的价格取不到。", - "trades": "本期没有可排序的标的层诊断。", - "trades_traded": "本期没有可排序的标的层诊断;这期交易过:{tickers}。", + "annualized_reconciliation": "本期算不出年化报酬:汇入的交易纪录推不出快照锚定的持仓,没有可用的账户估值基础。把你目前的持仓画面给我,这个差异就能对清楚。", + "annualized_short_series": "本期算不出年化报酬:价格序列不足两天,没有可衡量的变化。", "risks": "本期没有行为诊断:引擎没有可评分的维度。", "rule": "这次没有新的规矩承诺;下次复盘可再选一条。", - "rule_standing": "这期不改规矩;现行规矩维持:“{rule}”", - "rule_pending": "这期还没选规矩;建议是:“{rule}”", "rule_diverged": "现行规矩仍是“{standing}”;这期最该处理的是另一件事,“{recommendation}”在你选定之前都只是建议。", + "rule_insufficient_data": "样本仍短,这次不硬塞承诺;先把它当基线。", + "rule_pending": "这期还没选规矩;建议是:“{rule}”", "rule_skip": "你这次选择不设新承诺;下次仍可用同一份基线对账。", "rule_snapshot": "这次开场检查先保留结构基线,不强迫设定承诺。", + "rule_standing": "这期不改规矩;现行规矩维持:“{rule}”", "rule_structural": "这次是开场结构检查:先立基线、不硬塞承诺。已平仓的交易还太少,还看不出你的卖出是不是比买进更伤你;补上对应的卖出纪录就能回答这一题。", - "rule_insufficient_data": "样本仍短,这次不硬塞承诺;先把它当基线。", "snapshot_unlock": "这次只看得到你手上有什么,看不到你是怎么走到这里的。交易纪录能回答它唯一答不了的那一题:你的卖出是不是比买进更伤你。", - "annualized_short_series": "本期算不出年化报酬:价格序列不足两天,没有可衡量的变化。", - "annualized_reconciliation": "本期算不出年化报酬:汇入的交易纪录推不出快照锚定的持仓,没有可用的账户估值基础。把你目前的持仓画面给我,这个差异就能对清楚。" - }, - "account_gate": { - "default": "补齐现金锚点,就能看到含现金的账户级报酬。", - "no_cash_anchor": "补一个目前的现金余额,就能看到含现金的账户级报酬。", - "mixed_trade_footprint": "交易纪录里只有一部分带券商的现金金额,两种口径并不成同一条现金史。", - "negative_cash_rollback": "补上还没有锚点的那个币别,就能看到含现金的账户级报酬。", - "cash_residual": "更新那一段的现金部位,就能看到含现金的账户级报酬。", - "chain_unavailable": "这个区间没有任何一天算得出可用的账户净值可以串成报酬。" - }, - "footnote_label": "资料备注", - "kpi": { - "pnl": "账面总损益", - "pnl_sub": "已实现 {realized} · 未实现 {unrealized}", - "payoff": "已实现盈亏比", - "payoff_sub": "赚 {win} vs 赔 {loss}", - "excess": "相对大盘", - "excess_sub": "β {beta}", - "alpha": "年化 α", - "alpha_unreliable": "统计上还不可信", - "curve": "这期走势", - "spark_caption": "高点 {peak} · 低点 {trough}" - }, - "etf_classification": { - "allocation": "配置型 ETF 已从单一股票集中度排除:{list}。", - "concentrated": "产业/主题/杠杆 ETF 仍算集中风险:{list}。" - }, - "dimensions": { - "exit_discipline": "出场纪律", - "position_sizing": "部位 sizing", - "diversification": "分散", - "holding_period": "持有时间", - "averaging_down": "加码摊平", - "alpha_beta": "大盘与选股归因", - "entry_style": "进场风格" + "trades": "本期没有可排序的标的层诊断。", + "trades_traded": "本期没有可排序的标的层诊断;这期交易过:{tickers}。", + "vs_market": "本期无法比对大盘:缺可用的基准序列。", + "vs_market_prices": "本期无法比对大盘:基准指数的价格取不到。" }, - "public_patterns": { - "exit_discipline": "这次浮现的模式,关乎出场决策如何被安排与检验。", - "position_sizing": "这次浮现的模式,关乎风险如何分配到不同部位。", - "diversification": "这次浮现的模式,关乎组合是否代表彼此不同的底层风险。", - "holding_period": "这次浮现的模式,关乎持有时间是否对齐一致的决策时间轴。", - "holding_period_same_day": "所有已完成交易均为当日进出,没有隔夜持有。", - "averaging_down": "这次浮现的模式,关乎亏损部位的加码如何受到界线约束。", - "alpha_beta": "这次浮现的模式,关乎相对大盘结果如何拆分为市场暴险与选择。", - "entry_style": "这次浮现的模式,关乎进场时点与近期价格移动的关系。" + "blocks": { + "next": "下一步", + "performance": "这期的绩效", + "risks": "风险与问题", + "summary": "总结", + "trades": "关键交易 · 按金额排序" }, - "rules": { - "exit_discipline": "出场前先写下交易论述已完成或失效的事实;只因害怕回吐,不卖。", - "position_sizing": "单笔部位上限定死 {cap};超过就减,不新增。", - "diversification": "新增部位前先看是否仍是同一个驱动因子;同一注已过重,不新增。", - "holding_period": "进场时先标短线、波段或长线;出场只用同一时间框架的理由。", - "averaging_down": "往下加码前写出一个进场时不知道的新证据;写不出,不加。" + "breach_evidence": { + "joiner": "; ", + "more": ";另有 {extra} 笔", + "none": "账上有一笔事件" }, - "rule_grounding": { - "position_sizing": "本期实况:最大单一部位 {ticker} 占 {pct}。", - "diversification": "本期实况:前几大部位 {tickers} 合计占 {pct}。", - "holding_period": "本期实况:{tickers} 同一档既有短进短出、也有长抱。", - "averaging_down": "本期实况:你在亏损部位往下加码 {count} 次,包括 {tickers}。" + "candidate_comparison": { + "joiner": "、", + "line": "本期“{top}”的讯号比“{others}”更强——这只反映本期哪个模式更明显,不代表哪条规矩更适合你。" }, - "rule_targets": { - "line": "本期会被它拦下的:{items}", - "count_unit": " 次", - "more_suffix": "、及其他 {n} 档" + "cash_lines": { + "amount_joiner": "、", + "anchored": "有余额锚点的账户现金为 {cash}。", + "anchored_with_weight": "有余额锚点的账户现金为 {cash},占账户 {weight}。", + "by_currency": "有余额锚点的账户现金(原币):{amounts}。" }, - "rule_tradeoff": { - "sizing_vs_proven_edge": "上限管的是单笔爆掉,不跟“选股是真 edge”冲突。", - "sizing_vs_hypothesis": "上限管的是单笔爆掉;押对赛道还没验证,更不该靠加大单注押它。" + "currency_note": { + "cached": "显示换算沿用上次对账汇率。", + "cached_dated": "显示换算沿用上次对账汇率(截至 {when})。", + "no_rate": "找不到可靠的 {currency} 显示汇率;金额保留原币,不做猜测换算。", + "portfolio_fx_gap": "至少一个持仓币别缺少可靠汇率;金额保留原币,不把近似聚合值当成精确换算。" }, - "patterns_panel": { - "label": "一个模式,还没下判断(不用回答)", - "sold_winner_early": "这期卖掉的 {total} 笔有 {early} 笔卖完还涨,最明显是 {tickers}。" + "decision_entries": { + "line": "{ticker}:{label}。这个判断与证据边界已保存,供下次对账。", + "ticker_default": "position" }, - "exit_consistency": { - "question": "这是你出场规则里一致的做法、情绪反应,还是外部限制?" + "demo_badge": "示范资料/演练,不是你的真实交易,也不写入正式教练记忆", + "dimensions": { + "alpha_beta": "大盘与选股归因", + "averaging_down": "加码摊平", + "diversification": "分散", + "entry_style": "进场风格", + "exit_discipline": "出场纪律", + "holding_period": "持有时间", + "position_sizing": "部位 sizing" }, - "add_choices": { - "planned_tranche": "事先规划的分批", - "new_evidence": "有新的可验证证据", - "valuation_change": "估值或赔率变了", - "price_only": "只有价格下跌/想摊低成本", - "skip": "先跳过" + "due_choices": { + "falsified": "看错了", + "modified": "部分对,要调", + "skip": "先跳过", + "still_valid": "还成立" }, - "headline_motive_choices": { - "deliberate_plan": "事先规划", - "emotional_reaction": "情绪反应", - "external_constraint": "外部限制" + "due_descriptions": { + "falsified": "真的判断错误,记进教训。", + "modified": "理由部分成立,需要修正。", + "skip": "不算已回答,下次复盘同一关会再出现。", + "still_valid": "理由仍成立,卖早也是纪律。" }, - "initial_thesis_choices": { - "planned_entry": "进场前就有明确论点", - "momentum_follow": "追了动能/FOMO", - "external_call": "别人推荐的", - "no_clear_thesis": "没有明确论点", - "skip": "先跳过" + "etf_classification": { + "allocation": "配置型 ETF 已从单一股票集中度排除:{list}。", + "concentrated": "产业/主题/杠杆 ETF 仍算集中风险:{list}。" }, "exit_choices": { "full": { - "price_target": "到价了", - "thesis_broken": "原本判断失效", - "swap": "换到更好的标的", "anxiety": "想先落袋", "other": "其他理由", - "skip": "先不回答" + "price_target": "到价了", + "skip": "先不回答", + "swap": "换到更好的标的", + "thesis_broken": "原本判断失效" }, "reduce": { - "price_target": "到了减码点", - "thesis_broken": "信心动摇", - "swap": "换到更好的标的", "anxiety": "想先落袋一部分", "other": "其他理由", - "skip": "先不回答" + "price_target": "到了减码点", + "skip": "先不回答", + "swap": "换到更好的标的", + "thesis_broken": "信心动摇" } }, - "honesty": { - "snapshot_scope": "交易历史维度——摊平、出场纪律、持有行为、胜率、盈亏比、alpha 与历史动机——不在这次持仓快照的评分范围内。", - "alpha_credibility": "Alpha 的样本或统计强度不足,不能当成稳定能力。", - "sector_attribution": "部分标的缺板块基准,赛道与选股拆帐不完整。", - "unclassified_drivers": "部分标的尚未分类,组合集中度可能被低估。", - "price_source": "这期的现价不是引擎自己抓到的:不是来自明确标示的外部资料源与截止日,就是根本取不到,卡上会说明是哪一种。", - "price_plausibility": "有供给的收盘价与该档最近一次实际成交价落差过大,这个来源在采信前应该重新核实。", - "unrealized_coverage": "部分持仓缺现价,未实现损益不是完整账面。", - "orphan_sells": "有卖出缺少已知建仓,相关已实现损益未纳入。", - "unpriced_exits": "有仓位离开记录时没有成交价,这些出场未纳入胜率、盈亏比与出场纪律。", - "currency_mix": "组合含多币别;聚合数字使用共同币别,部分汇率可能为近似。", - "cash_reliability": "现金余额缺完整锚点,含现金的账户判读有限。", - "acct_perf_basis": "账户绩效建立在近似的地基上——部分锚点、成本平线计价、汇率近似、用量×价估算的交易现金金额,或一段完全没有存提款纪录的期间——需保留不确定性。", - "accounting_reconciliation": "汇入交易未能重现快照锚定的目前持仓,因此这次不评分部位大小、分散与未实现损益。", - "snapshot_reconciliation": "这份持仓宣告已与账本推导持仓对账;一致即标记为已对账,若有差异则以明确的调整事件保留纪录,并改以较新宣告为记账锚点,不猜测差异原因。", - "etf_metadata": "ETF 费用率或 tracking error 资料不完整,本卡没有把缺值猜成零。", - "prior_commitment_breach": "这条规矩本期已经记到一笔违规事件,在这次复盘结算之前就已经发生。" + "exit_consistency": { + "question": "这是你出场规则里一致的做法、情绪反应,还是外部限制?" }, - "instrument_tags": { - "suspected_averaging_down_losing": "✗疑似凹单:只在亏损加码 {n_adds} 次、现亏 {cur_pct}{price_note}(待你确认交易论述)", - "suspected_averaging_down_recovered": "⚠疑似凹单(现赚):只在亏损加码 {n_adds} 次——赚回来像运气,不是纪律", - "adds_pending_confirmation": "?加码 {n_adds} 次待确认:是定投还是凹单,要你定", - "suspected_dca": "✓疑似定投:涨跌都买/规律 {n_adds} 次,不是凹单", - "deep_underwater": "✗套牢:{cur_pct}{price_note} 还抱着没处理", - "too_heavy": "⚠押太重:占组合 {wpct_pct}", - "disciplined_hold": "✓纪律持有:赚 {cur_pct}{price_note}", - "roughly_neutral": "— 大致中性" + "exit_descriptions": { + "anxiety": "主要是怕回吐,所以先锁住全部或部分成果。", + "other": "以上都不符合,用自己的话留下一句。", + "price_target": "原先设定的目标或减码条件已经完成。", + "skip": "保存为已略过,之后不再追问这笔的卖出理由。", + "swap": "资金改放到另一个标的或用途。", + "thesis_broken": "原本判断失效,或信心因新事实而下降。" }, - "stress_test": { - "labels": { - "ai_thematic": "AI 概念股(跨板块)", - "sector": "“{sector}”板块", - "single_ticker": "单档 {ticker}" - }, - "line": "你 {label} 暴险约 {exposure}(占 {pct});回档 30% → 账面 −{drop30}、回档 50% → −{drop50},撑得住吗?" + "exit_entries": { + "action_full": "清仓", + "action_partial": "减仓", + "line": "{ticker}:你把这次{action}记为“{label}”。这里只保存当时的理由,尚未判断决策结果。", + "ticker_default": "这笔部位" }, - "prescription_kinds": { - "amplify": "扬长", - "amplify_hypothesis": "扬长(假设,待验证)", - "selection_inconclusive": "选股:资料不足以判定", - "outsource": "外包短板(渐进)", - "cut_loss": "砍损耗" + "exit_followup": { + "action_full": "清仓", + "action_reduce": "减仓", + "as_of": "(截至 {as_of})", + "backlog": "历史出场 backlog 尚有 {count} 笔未复核(清仓 {full}、减仓 {reduce})", + "backlog_priced": " 有现价可回看的 {priced} 笔,出场后平均走势为 {avg};其中 {rose} 笔后续上涨。", + "backlog_span": ",期间 {first} 到 {last}", + "backlog_top": ";最常出现:{top}", + "check": "{ticker},{checkpoint} 天复核:{label}。", + "focus": "Backlog 优先回看:{ticker},{exit_date} {action}。", + "focus_idle": " 卖后资金闲置,原标的同期 {orig}{when}。", + "focus_needs_prices": " {missing} 缺冻结现价{when},不判结果。", + "focus_orig": " 原标的后续 {orig}{when}。", + "focus_swap": " 原标的后续 {orig}{when};换入标的同期 {swap};swap 净差 {net}。", + "frozen_lead": " 以 {as_of} 冻结现价计,", + "frozen_lead_absent": " ", + "frozen_note": "(以 {as_of} 冻结现价计)", + "idle": " 卖后资金闲置,原标的同期 {orig}{when}。", + "needs_prices": " {missing} 缺现价{when},本期不判结果。", + "note": " 注记:“{note}”。", + "position_fallback": "这笔部位", + "replacement_fallback": "换入标的", + "sentence_end": "。", + "swap": "{lead}原标的后续 {orig};{swaps}同期 {swap};swap 净差 {net}。" }, - "prescription_texts": { - "amplify_hypothesis": "你赢大盘 {excess_pp}pp 里,押对赛道占 {alloc_pp}pp(拆帐)。那还只是假设,不是已证实的 edge——押对 AI 也可能只是站到风口,要靠下次的赛道判断才验得出来。", - "selection_inconclusive": "拆帐看,板块内选股贡献 {sel_pp}pp(描述性、这数字站得住);但 {t_note},统计上还分不出选股是本事还是运气。", - "selection_inconclusive_t_wide": "α 的 95% 区间还太宽(t={t})", - "selection_inconclusive_t_unstable": "α 统计量还算不稳", - "outsource_selection": "扣掉赛道,你板块内选股贡献 {sel_pp}pp、α 统计上站得住地差。这不是说‘别选股’(你享受它、ETF 也会错过妖股),而是可以拨一部分资金被动化托底,选股当卫星。(流程观察,非标的建议)", - "amplify_selection_edge": "扣掉赛道红利,你板块内选股仍贡献 {sel_pp}pp、α 统计上站得住——这是真 edge。", - "cut_averaging_down": "亏损中加码 {count} 次是你操盘损耗的大宗——这是最该先砍的纯扣分动作。", - "cut_oversize": "最大一笔 {ticker} 占 {max_pct_pct},单一押注过重。" - }, - "due_choices": { - "still_valid": "还成立", - "modified": "部分对,要调", - "falsified": "看错了", + "footnote_label": "资料备注", + "generic_choices": { + "deliberate_plan": "事先规划", + "emotional_reaction": "情绪反应", + "external_constraint": "外部限制", "skip": "先跳过" }, - "rule_breach_choices": { - "keep_tracking": "这次守不住", - "revise_rule": "规矩需要修订", - "exception": "这次是正当例外" + "generic_descriptions": { + "deliberate_plan": "行动遵循交易前就存在的规则。", + "emotional_reaction": "恐惧、后悔或急迫感主导了行动。", + "external_constraint": "资金、税务或其他限制主导了行动。", + "skip": "这次先不替动机下定论。" }, - "rule_breach_decisions": { - "keep_tracking": "这次没守住,继续追踪", - "revise_rule": "标记为需要修订", - "exception": "记为正当例外" + "headline_motive_choices": { + "deliberate_plan": "事先规划", + "emotional_reaction": "情绪反应", + "external_constraint": "外部限制" + }, + "hole_lines": { + "averaging_down": "你有 {count} 次在亏损仓往下加码({tickers}),其中 {breach} 次加码当下占成本 >25%", + "diversification": "你持有 {n} 档看似分散,但 AI capex 暴险 {ai_pct}、最大板块“{max_sector}”{max_sector_pct}、top3 {top3}——同一个驱动因子", + "exit_forward": "{n_rt} 笔决策卖出({n_scored} 有 fwd、{n_trunc} 截断)中 {early_rate} 在 {n_fwd} 天后更高、平均续涨 {avg_forgone}{winner_early}", + "exit_holding": "赚钱抱 {hold_win} 天 / 赔钱抱 {hold_lose} 天(处置缺口 {disp_gap})", + "exit_winner_early": ";卖掉赚钱的有 {winner_early} 续涨(卖太早)", + "holding_base": "你持有时间 {min}~{max} 天、中位 {median_hold} 天", + "holding_consistent": "{base}(中位 {median_hold} 天 = 你的主框架;同档框架大致一致)", + "holding_inconsistent": "{base};其中 {n_incon}/{n_multi} 档同一档又当冲又长抱({tickers})——同档没有一致框架", + "holding_no_data": "暂无已实现 round-trip,持有时间统计待生成(只看买进尚未卖出的不纳入)", + "holding_same_day": "所有已完成交易均为当日进出,没有隔夜持有", + "joiner": ";", + "position_sizing": "你最大一笔 {max_ticker} 占 {max_pct},其余平均 {avg_pct}", + "ticker_joiner": ", " + }, + "honesty": { + "accounting_reconciliation": "汇入交易未能重现快照锚定的目前持仓,因此这次不评分部位大小、分散与未实现损益。", + "acct_perf_basis": "账户绩效建立在近似的地基上——部分锚点、成本平线计价、汇率近似、用量×价估算的交易现金金额,或一段完全没有存提款纪录的期间——需保留不确定性。", + "alpha_credibility": "Alpha 的样本或统计强度不足,不能当成稳定能力。", + "cash_reliability": "现金余额缺完整锚点,含现金的账户判读有限。", + "currency_mix": "组合含多币别;聚合数字使用共同币别,部分汇率可能为近似。", + "etf_metadata": "ETF 费用率或 tracking error 资料不完整,本卡没有把缺值猜成零。", + "orphan_sells": "有卖出缺少已知建仓,相关已实现损益未纳入。", + "price_plausibility": "有供给的收盘价与该档最近一次实际成交价落差过大,这个来源在采信前应该重新核实。", + "price_source": "这期的现价不是引擎自己抓到的:不是来自明确标示的外部资料源与截止日,就是根本取不到,卡上会说明是哪一种。", + "prior_commitment_breach": "这条规矩本期已经记到一笔违规事件,在这次复盘结算之前就已经发生。", + "sector_attribution": "部分标的缺板块基准,赛道与选股拆帐不完整。", + "snapshot_reconciliation": "这份持仓宣告已与账本推导持仓对账;一致即标记为已对账,若有差异则以明确的调整事件保留纪录,并改以较新宣告为记账锚点,不猜测差异原因。", + "snapshot_scope": "交易历史维度——摊平、出场纪律、持有行为、胜率、盈亏比、alpha 与历史动机——不在这次持仓快照的评分范围内。", + "unclassified_drivers": "部分标的尚未分类,组合集中度可能被低估。", + "unpriced_exits": "有仓位离开记录时没有成交价,这些出场未纳入胜率、盈亏比与出场纪律。", + "unrealized_coverage": "部分持仓缺现价,未实现损益不是完整账面。" + }, + "horizon_entries": { + "exit_too_fast": "{ticker}:{voice}的 thesis 时间轴是“{horizon}”,{days} 天后就出场;这是时间轴不一致,不替动机下定论。", + "held_too_long": "{ticker}:{voice}的 thesis 时间轴是“{horizon}”,持有 {days} 天后仍未结束;时间轴已漂移,仍需厘清。", + "ticker_default": "这笔部位", + "voice_inferred": "原先推测", + "voice_recorded": "已记录" }, "horizons": { - "weeks": "周", "quarters": "季", + "weeks": "周", "years": "年" }, - "problem_keys": { - "avgdown_breach": "摊平越过部位界线", - "sell_winner_early": "卖出赢家的机会成本", - "oversize": "单一部位过重", - "concentration": "同一驱动因子集中", - "hold_inconsistency": "持有时间轴混用", - "exit_anxiety": "焦虑驱动出场", - "fomo_entry": "FOMO 进场", - "horizon_break": "交易论述时间轴破戒" + "initial_thesis_choices": { + "external_call": "别人推荐的", + "momentum_follow": "追了动能/FOMO", + "no_clear_thesis": "没有明确论点", + "planned_entry": "进场前就有明确论点", + "skip": "先跳过" }, - "trends": { - "worse": "恶化", - "flat": "持平", - "better": "改善" + "initial_thesis_descriptions": { + "external_call": "KOL、朋友或研究报告推荐的。", + "momentum_follow": "追了价格动能或 FOMO。", + "no_clear_thesis": "在没有明确论点下持有。", + "planned_entry": "进场前就有明确的论点。", + "skip": "这次先不替进场动机下定论。" + }, + "instrument_tags": { + "adds_pending_confirmation": "?加码 {n_adds} 次待确认:是定投还是凹单,要你定", + "deep_underwater": "✗套牢:{cur_pct}{price_note} 还抱着没处理", + "disciplined_hold": "✓纪律持有:赚 {cur_pct}{price_note}", + "roughly_neutral": "— 大致中性", + "suspected_averaging_down_losing": "✗疑似凹单:只在亏损加码 {n_adds} 次、现亏 {cur_pct}{price_note}(待你确认交易论述)", + "suspected_averaging_down_recovered": "⚠疑似凹单(现赚):只在亏损加码 {n_adds} 次——赚回来像运气,不是纪律", + "suspected_dca": "✓疑似定投:涨跌都买/规律 {n_adds} 次,不是凹单", + "too_heavy": "⚠押太重:占组合 {wpct_pct}" + }, + "kpi": { + "alpha": "年化 α", + "alpha_unreliable": "统计上还不可信", + "curve": "这期走势", + "excess": "相对大盘", + "excess_sub": "β {beta}", + "payoff": "已实现盈亏比", + "payoff_sub": "赚 {win} vs 赔 {loss}", + "pnl": "账面总损益", + "pnl_sub": "已实现 {realized} · 未实现 {unrealized}", + "spark_caption": "高点 {peak} · 低点 {trough}" + }, + "language": "zh-CN", + "markdown_summary": { + "next": "下次规则", + "risk": "主要风险" + }, + "motive_entries": { + "context": "引擎脉络:{fact}。", + "recorded": "“{subject}”的动机记为:{label}。", + "saved": "这个选项已保存,供后续复盘对账。", + "subject_default": "这次浮现的行为", + "terminator": "。" + }, + "patterns_panel": { + "label": "一个模式,还没下判断(不用回答)", + "sold_winner_early": "这期卖掉的 {total} 笔有 {early} 笔卖完还涨,最明显是 {tickers}。" + }, + "payoff_lines": { + "drag_plain": "最大已实现拖累是 {ticker};拿掉它后盈亏比会是 {after}。", + "drag_with_amount": "最大已实现拖累是 {ticker},净影响 {drag};拿掉它后盈亏比会是 {after}。", + "original_currency": "已实现盈亏比 {payoff};平均盈亏金额因显示汇率缺失而保留原币。", + "sub_wrap": "({sub})" + }, + "period": { + "as_of": "截至 {end}", + "span": "复盘区间 {start} → {end}", + "vix": "VIX {value}" }, "pnl_lines": { "display": { - "total": "账面总损益 {total},其中已实现 {realized}、未实现 {unrealized}。", "realized_only": "已实现损益 {realized};目前未实现损益未评分。", + "total": "账面总损益 {total},其中已实现 {realized}、未实现 {unrealized}。", "unrealized_only": "未实现损益 {unrealized};已实现损益无法取得。" }, "original": { @@ -263,258 +334,200 @@ "unrealized_only": "{currency} 未实现损益 {unrealized}。" } }, - "alpha_interval": { - "scope_suffix": "({market} 部位)", - "negative_caveat": "(区间包含负值,代表这段期间的选股优势在统计上还不能视为稳定能力)", - "lead": "风险调整后 alpha{scope} 年化 {alpha}%,95% 区间为 {low}% 到 {high}%;定论强度以这个区间为准。", - "tile_sub": "95% 区间 {low}%~{high}%", - "below_unreliable": "* 年化 α 统计上还不可信。", - "below_negative": "年化 α 的区间包含负值,代表这段期间的选股优势在统计上还不能视为稳定能力。" - }, - "best_strength": { - "no_signal": "这期没有足够强的正向讯号;先把注意力留给最大的洞。", - "exit_discipline": "该获利了结时你不手软:卖掉的赚钱单只有 {winner_early} 事后继续涨,代表你不会“抱着赚钱的舍不得卖、结果回吐”", - "position_sizing": "单笔部位有控制:押最重的一档也只占 {max_pct},没把身家压在一档上", - "averaging_down": "你往下加码 {count} 次,却都守在自己的部位上限内、没让任何一档越摊越重{example}", - "averaging_down_example": "(例:{ticker})", - "diversification": "{n} 档分布在不同驱动因子,没有全押在同一个故事上", - "holding_period": "进出有一致的节奏:中位持有 {median_hold} 天,不是随机乱买乱卖" + "prescription_kinds": { + "amplify": "扬长", + "amplify_hypothesis": "扬长(假设,待验证)", + "cut_loss": "砍损耗", + "outsource": "外包短板(渐进)", + "selection_inconclusive": "选股:资料不足以判定" }, - "reconciliation": { - "statement": "上次你承诺:“{rule}”。", - "statement_with_metric": "上次你承诺:“{rule}”——追踪的数字当时 {then},这次 {now}。" + "prescription_texts": { + "amplify_hypothesis": "你赢大盘 {excess_pp}pp 里,押对赛道占 {alloc_pp}pp(拆帐)。那还只是假设,不是已证实的 edge——押对 AI 也可能只是站到风口,要靠下次的赛道判断才验得出来。", + "amplify_selection_edge": "扣掉赛道红利,你板块内选股仍贡献 {sel_pp}pp、α 统计上站得住——这是真 edge。", + "cut_averaging_down": "亏损中加码 {count} 次是你操盘损耗的大宗——这是最该先砍的纯扣分动作。", + "cut_oversize": "最大一笔 {ticker} 占 {max_pct_pct},单一押注过重。", + "outsource_selection": "扣掉赛道,你板块内选股贡献 {sel_pp}pp、α 统计上站得住地差。这不是说‘别选股’(你享受它、ETF 也会错过妖股),而是可以拨一部分资金被动化托底,选股当卫星。(流程观察,非标的建议)", + "selection_inconclusive": "拆帐看,板块内选股贡献 {sel_pp}pp(描述性、这数字站得住);但 {t_note},统计上还分不出选股是本事还是运气。", + "selection_inconclusive_t_unstable": "α 统计量还算不稳", + "selection_inconclusive_t_wide": "α 的 95% 区间还太宽(t={t})" }, - "snapshot": { - "overview": { - "subject_with_count": "使用者提供的 {positions} 个持仓", - "subject_generic": "使用者提供的持仓", - "opening": "这是针对{subject}的开场组合检查。", - "opening_as_of": "这是针对{subject}的开场组合检查,快照截至 {as_of}。", - "valuation_market_value": "结构权重采使用者提供的市值口径。", - "valuation_cost": "结构权重采使用者提供的成本口径。", - "valuation_unavailable": "目前没有可靠估值口径,因此不评分依赖权重的组合结构。", - "missing_avg_cost": "以下持仓缺少平均成本:{tickers}。", - "missing_fx": "以下币别缺少可靠汇率:{currencies}。" - }, - "strength": { - "weighted": "已用你提供的持仓建立组合结构基线,并据此评分权重;缺少的输入维持明示,不用推测补齐。", - "baseline": "已用使用者提供的持仓建立结构基线;依赖权重的优势暂不评分。" - }, - "holes": { - "leading_risk": "现有快照的主要结构风险是“{label}”。", - "clean_structure": "这次持仓快照没有触发集中度或分散度的结构风险。", - "no_weights": "这次开场检查只建立结构基线;无法取得的权重不会被当成低风险。" - } + "private_badge": "复盘卡,只留在本机", + "problem_keys": { + "avgdown_breach": "摊平越过部位界线", + "concentration": "同一驱动因子集中", + "exit_anxiety": "焦虑驱动出场", + "fomo_entry": "FOMO 进场", + "hold_inconsistency": "持有时间轴混用", + "horizon_break": "交易论述时间轴破戒", + "oversize": "单一部位过重", + "sell_winner_early": "卖出赢家的机会成本" }, "problems": { - "trend_line": "{name}:近期 {recent} 次,前期 {prev} 次({trend})。", "breach_decision": "规矩“{rule}”:{label}。", "breach_note": " 注记:“{note}”。", - "rule_held": "规矩“{rule}”在最近一个可观测周期守住了。" + "rule_held": "规矩“{rule}”在最近一个可观测周期守住了。", + "trend_line": "{name}:近期 {recent} 次,前期 {prev} 次({trend})。" }, - "exit_followup": { - "position_fallback": "这笔部位", - "check": "{ticker},{checkpoint} 天复核:{label}。", - "note": " 注记:“{note}”。", - "as_of": "(截至 {as_of})", - "needs_prices": " {missing} 缺现价{when},本期不判结果。", - "replacement_fallback": "换入标的", - "frozen_lead": " 以 {as_of} 冻结现价计,", - "frozen_lead_absent": " ", - "swap": "{lead}原标的后续 {orig};{swaps}同期 {swap};swap 净差 {net}。", - "frozen_note": "(以 {as_of} 冻结现价计)", - "idle": " 卖后资金闲置,原标的同期 {orig}{when}。", - "backlog": "历史出场 backlog 尚有 {count} 笔未复核(清仓 {full}、减仓 {reduce})", - "backlog_span": ",期间 {first} 到 {last}", - "backlog_top": ";最常出现:{top}", - "sentence_end": "。", - "backlog_priced": " 有现价可回看的 {priced} 笔,出场后平均走势为 {avg};其中 {rose} 笔后续上涨。", - "action_full": "清仓", - "action_reduce": "减仓", - "focus": "Backlog 优先回看:{ticker},{exit_date} {action}。", - "focus_needs_prices": " {missing} 缺冻结现价{when},不判结果。", - "focus_swap": " 原标的后续 {orig}{when};换入标的同期 {swap};swap 净差 {net}。", - "focus_idle": " 卖后资金闲置,原标的同期 {orig}{when}。", - "focus_orig": " 原标的后续 {orig}{when}。" + "public_badge": "可分享版,已移除金额、日期、标的与精确部位", + "public_band": { + "high": "高", + "low": "低", + "moderate": "中", + "very_high": "很高" }, - "account_perf": { - "holdings_only": "仅计持仓的累积报酬为 {value}。", - "account_base": "账户级累积报酬为 {value}", - "annualized_suffix": ",年化报酬 {value}", - "cash_drag_suffix": ";与仅计持仓的差距 {value} 来自持有现金——这是观察,不是对错判定", - "terminator": "。" + "public_custom_rule": "已承诺一条自订流程规矩;内容只留在复盘卡。", + "public_mirror": { + "behavioral": "这次复盘在“{dim}”看见{severity}程度的行为压力。", + "behavioral_dim_default": "主要行为维度", + "no_holes": "这次可用历史不足以排序出主要行为模式。", + "snapshot_baseline": "这次开场组合检查只建立结构基线;交易历史行为维度维持未评分。", + "structural": "这次开场组合检查把“{dim}”列为主要结构风险。", + "structural_dim_default": "组合结构", + "structural_with_severity": "这次开场组合检查把“{dim}”列为主要结构风险,风险压力为{severity}。", + "structure": "配置型 ETF 与单一标的风险分开计算;产业、主题与杠杆 ETF 仍保留集中风险。" }, - "currency_note": { - "cached_dated": "显示换算沿用上次对账汇率(截至 {when})。", - "cached": "显示换算沿用上次对账汇率。", - "portfolio_fx_gap": "至少一个持仓币别缺少可靠汇率;金额保留原币,不把近似聚合值当成精确换算。", - "no_rate": "找不到可靠的 {currency} 显示汇率;金额保留原币,不做猜测换算。" + "public_patterns": { + "alpha_beta": "这次浮现的模式,关乎相对大盘结果如何拆分为市场暴险与选择。", + "averaging_down": "这次浮现的模式,关乎亏损部位的加码如何受到界线约束。", + "diversification": "这次浮现的模式,关乎组合是否代表彼此不同的底层风险。", + "entry_style": "这次浮现的模式,关乎进场时点与近期价格移动的关系。", + "exit_discipline": "这次浮现的模式,关乎出场决策如何被安排与检验。", + "holding_period": "这次浮现的模式,关乎持有时间是否对齐一致的决策时间轴。", + "holding_period_same_day": "所有已完成交易均为当日进出,没有隔夜持有。", + "position_sizing": "这次浮现的模式,关乎风险如何分配到不同部位。" }, - "benchmark_line": { - "line": "{subject}对 {comparator} 的超额报酬 {excess} 个百分点{beta}。", - "subject_market": "{market} 部位", - "subject_default": "持仓", - "comparator_default": "市场大盘", - "beta_suffix": ";β {beta}" + "public_performance": { + "line": "{subject}:相对各自市场大盘 {excess} 个百分点;β {beta}。", + "subject_default": "可比较部位" }, - "split_lines": { - "line": "{subject}赢大盘的 {excess} 个百分点拆为:市场/赛道配置 {allocation} 个百分点、标的选择 {selection} 个百分点。", - "subject_market": "{market} ", - "subject_default": "" + "rationale_refresh": { + "stem": "你在 {stated_at} 说你持有 {ticker} 是因为:「{statement}」。现在还是这个理由吗?" }, - "decision_entries": { - "line": "{ticker}:{label}。这个判断与证据边界已保存,供下次对账。", - "ticker_default": "position" + "rationale_refresh_choices": { + "changed": "理由变了", + "same": "还是同一个理由", + "skip": "先不回答" }, - "motive_entries": { - "subject_default": "这次浮现的行为", - "context": "引擎脉络:{fact}。", - "recorded": "“{subject}”的动机记为:{label}。", - "saved": "这个选项已保存,供后续复盘对账。", - "terminator": "。" + "rationale_refresh_choices_descriptions": { + "changed": "写下现在的理由。旧的字句会留着,不是被覆盖。", + "same": "以今天的日期记下你确认过。你原本的字句原封不动保留。", + "skip": "先不回答;你先前说的仍然有效。" }, - "exit_entries": { - "line": "{ticker}:你把这次{action}记为“{label}”。这里只保存当时的理由,尚未判断决策结果。", - "ticker_default": "这笔部位", - "action_full": "清仓", - "action_partial": "减仓" + "reconciliation": { + "statement": "上次你承诺:“{rule}”。", + "statement_with_metric": "上次你承诺:“{rule}”——追踪的数字当时 {then},这次 {now}。" }, "review_milestone": { "line": "开始这次复盘时,你已有 {completed} 次完成纪录。", - "noun_one": "", - "noun_many": "" + "noun_many": "", + "noun_one": "" }, - "horizon_entries": { - "ticker_default": "这笔部位", - "voice_inferred": "原先推测", - "voice_recorded": "已记录", - "exit_too_fast": "{ticker}:{voice}的 thesis 时间轴是“{horizon}”,{days} 天后就出场;这是时间轴不一致,不替动机下定论。", - "held_too_long": "{ticker}:{voice}的 thesis 时间轴是“{horizon}”,持有 {days} 天后仍未结束;时间轴已漂移,仍需厘清。" + "rule_breach_choices": { + "exception": "这次是正当例外", + "keep_tracking": "这次守不住", + "revise_rule": "规矩需要修订" }, - "public_band": { - "low": "低", - "moderate": "中", - "high": "高", - "very_high": "很高" + "rule_breach_decisions": { + "exception": "记为正当例外", + "keep_tracking": "这次没守住,继续追踪", + "revise_rule": "标记为需要修订" }, - "public_performance": { - "line": "{subject}:相对各自市场大盘 {excess} 个百分点;β {beta}。", - "subject_default": "可比较部位" + "rule_breach_descriptions": { + "exception": "这次有正当例外;在 note 留下理由,事件仍保留在账上。", + "keep_tracking": "规矩合理,但这次没有守住;照实记录并继续追。", + "revise_rule": "规矩本身不合理;在 note 简述为何要改,收尾时用唯一 commitment 写替代规矩。" }, - "payoff_lines": { - "sub_wrap": "({sub})", - "original_currency": "已实现盈亏比 {payoff};平均盈亏金额因显示汇率缺失而保留原币。", - "drag_with_amount": "最大已实现拖累是 {ticker},净影响 {drag};拿掉它后盈亏比会是 {after}。", - "drag_plain": "最大已实现拖累是 {ticker};拿掉它后盈亏比会是 {after}。" + "rule_grounding": { + "averaging_down": "本期实况:你在亏损部位往下加码 {count} 次,包括 {tickers}。", + "diversification": "本期实况:前几大部位 {tickers} 合计占 {pct}。", + "holding_period": "本期实况:{tickers} 同一档既有短进短出、也有长抱。", + "position_sizing": "本期实况:最大单一部位 {ticker} 占 {pct}。" }, - "cash_lines": { - "anchored_with_weight": "有余额锚点的账户现金为 {cash},占账户 {weight}。", - "anchored": "有余额锚点的账户现金为 {cash}。", - "by_currency": "有余额锚点的账户现金(原币):{amounts}。", - "amount_joiner": "、" + "rule_targets": { + "count_unit": " 次", + "line": "本期会被它拦下的:{items}", + "more_suffix": "、及其他 {n} 档" }, - "public_mirror": { - "structural_with_severity": "这次开场组合检查把“{dim}”列为主要结构风险,风险压力为{severity}。", - "structural": "这次开场组合检查把“{dim}”列为主要结构风险。", - "structural_dim_default": "组合结构", - "snapshot_baseline": "这次开场组合检查只建立结构基线;交易历史行为维度维持未评分。", - "no_holes": "这次可用历史不足以排序出主要行为模式。", - "behavioral": "这次复盘在“{dim}”看见{severity}程度的行为压力。", - "behavioral_dim_default": "主要行为维度", - "structure": "配置型 ETF 与单一标的风险分开计算;产业、主题与杠杆 ETF 仍保留集中风险。" + "rule_tradeoff": { + "sizing_vs_hypothesis": "上限管的是单笔爆掉;押对赛道还没验证,更不该靠加大单注押它。", + "sizing_vs_proven_edge": "上限管的是单笔爆掉,不跟“选股是真 edge”冲突。" }, - "tag_values": { - "price_note": "(现 {px}/均 {avg_cost})" + "rules": { + "averaging_down": "往下加码前写出一个进场时不知道的新证据;写不出,不加。", + "diversification": "新增部位前先看是否仍是同一个驱动因子;同一注已过重,不新增。", + "exit_discipline": "出场前先写下交易论述已完成或失效的事实;只因害怕回吐,不卖。", + "holding_period": "进场时先标短线、波段或长线;出场只用同一时间框架的理由。", + "position_sizing": "单笔部位上限定死 {cap};超过就减,不新增。" + }, + "sections": { + "etf": "ETF 与组合结构", + "hole": "最大的行为漏洞", + "performance": "相对绩效", + "rule": "下次只改这一件", + "strength": "你做对的一件事" }, "sectors": { "bonds": "债券", - "crypto": "加密", - "regional_etf": "区域ETF", - "semiconductors": "半导体", - "commodities": "商品", "broad_market_etf": "大盘ETF", - "unclassified": "未分类", + "commodities": "商品", "consumer": "消费", + "crypto": "加密", + "datacenter_power": "资料中心电力", "drones_defense": "无人机国防", + "ev_ai": "电动车AI", + "fintech": "金融科技", "rare_earth_materials": "稀土材料", - "datacenter_power": "资料中心电力", + "regional_etf": "区域ETF", + "semiconductors": "半导体", "software_cloud": "软件云", - "fintech": "金融科技", "telecom": "电信", - "ev_ai": "电动车AI" - }, - "hole_lines": { - "joiner": ";", - "ticker_joiner": ", ", - "exit_forward": "{n_rt} 笔决策卖出({n_scored} 有 fwd、{n_trunc} 截断)中 {early_rate} 在 {n_fwd} 天后更高、平均续涨 {avg_forgone}{winner_early}", - "exit_winner_early": ";卖掉赚钱的有 {winner_early} 续涨(卖太早)", - "exit_holding": "赚钱抱 {hold_win} 天 / 赔钱抱 {hold_lose} 天(处置缺口 {disp_gap})", - "position_sizing": "你最大一笔 {max_ticker} 占 {max_pct},其余平均 {avg_pct}", - "diversification": "你持有 {n} 档看似分散,但 AI capex 暴险 {ai_pct}、最大板块“{max_sector}”{max_sector_pct}、top3 {top3}——同一个驱动因子", - "holding_no_data": "暂无已实现 round-trip,持有时间统计待生成(只看买进尚未卖出的不纳入)", - "holding_same_day": "所有已完成交易均为当日进出,没有隔夜持有", - "holding_base": "你持有时间 {min}~{max} 天、中位 {median_hold} 天", - "holding_inconsistent": "{base};其中 {n_incon}/{n_multi} 档同一档又当冲又长抱({tickers})——同档没有一致框架", - "holding_consistent": "{base}(中位 {median_hold} 天 = 你的主框架;同档框架大致一致)", - "averaging_down": "你有 {count} 次在亏损仓往下加码({tickers}),其中 {breach} 次加码当下占成本 >25%" - }, - "add_descriptions": { - "planned_tranche": "进场前已定好节奏,价格下跌不是新增理由。", - "new_evidence": "必须补 claim 与 source,之后能回头验证。", - "valuation_change": "判断没变,但价格让赔率或安全边际改变。", - "price_only": "没有新事实,主要是想摊低成本或等回本。", - "skip": "先不定性,卡上只标未确认。" - }, - "generic_choices": { - "deliberate_plan": "事先规划", - "emotional_reaction": "情绪反应", - "external_constraint": "外部限制", - "skip": "先跳过" - }, - "generic_descriptions": { - "deliberate_plan": "行动遵循交易前就存在的规则。", - "emotional_reaction": "恐惧、后悔或急迫感主导了行动。", - "external_constraint": "资金、税务或其他限制主导了行动。", - "skip": "这次先不替动机下定论。" - }, - "initial_thesis_descriptions": { - "planned_entry": "进场前就有明确的论点。", - "momentum_follow": "追了价格动能或 FOMO。", - "external_call": "KOL、朋友或研究报告推荐的。", - "no_clear_thesis": "在没有明确论点下持有。", - "skip": "这次先不替进场动机下定论。" + "unclassified": "未分类" }, - "exit_descriptions": { - "price_target": "原先设定的目标或减码条件已经完成。", - "thesis_broken": "原本判断失效,或信心因新事实而下降。", - "swap": "资金改放到另一个标的或用途。", - "anxiety": "主要是怕回吐,所以先锁住全部或部分成果。", - "other": "以上都不符合,用自己的话留下一句。", - "skip": "保存为已略过,之后不再追问这笔的卖出理由。" - }, - "due_descriptions": { - "still_valid": "理由仍成立,卖早也是纪律。", - "modified": "理由部分成立,需要修正。", - "falsified": "真的判断错误,记进教训。", - "skip": "不算已回答,下次复盘同一关会再出现。" + "snapshot": { + "holes": { + "clean_structure": "这次持仓快照没有触发集中度或分散度的结构风险。", + "leading_risk": "现有快照的主要结构风险是“{label}”。", + "no_weights": "这次开场检查只建立结构基线;无法取得的权重不会被当成低风险。" + }, + "overview": { + "missing_avg_cost": "以下持仓缺少平均成本:{tickers}。", + "missing_fx": "以下币别缺少可靠汇率:{currencies}。", + "opening": "这是针对{subject}的开场组合检查。", + "opening_as_of": "这是针对{subject}的开场组合检查,快照截至 {as_of}。", + "subject_generic": "使用者提供的持仓", + "subject_with_count": "使用者提供的 {positions} 个持仓", + "valuation_cost": "结构权重采使用者提供的成本口径。", + "valuation_market_value": "结构权重采使用者提供的市值口径。", + "valuation_unavailable": "目前没有可靠估值口径,因此不评分依赖权重的组合结构。" + }, + "strength": { + "baseline": "已用使用者提供的持仓建立结构基线;依赖权重的优势暂不评分。", + "weighted": "已用你提供的持仓建立组合结构基线,并据此评分权重;缺少的输入维持明示,不用推测补齐。" + } }, - "rule_breach_descriptions": { - "keep_tracking": "规矩合理,但这次没有守住;照实记录并继续追。", - "revise_rule": "规矩本身不合理;在 note 简述为何要改,收尾时用唯一 commitment 写替代规矩。", - "exception": "这次有正当例外;在 note 留下理由,事件仍保留在账上。" + "snapshot_hole": "主要结构风险", + "snapshot_numbers": "第一次持仓结构", + "snapshot_strength": "这份快照建立了什么", + "split_lines": { + "line": "{subject}赢大盘的 {excess} 个百分点拆为:市场/赛道配置 {allocation} 个百分点、标的选择 {selection} 个百分点。", + "subject_default": "", + "subject_market": "{market} " }, - "asked_because": { - "pnl_impact": "它是你本周影响损益最大的部位", - "position_cost": "它是你本周成本最大的部位", - "exit_notional": "它是你近期金额最大的出场之一" + "stress_test": { + "labels": { + "ai_thematic": "AI 概念股(跨板块)", + "sector": "“{sector}”板块", + "single_ticker": "单档 {ticker}" + }, + "line": "你 {label} 暴险约 {exposure}(占 {pct});回档 30% → 账面 −{drop30}、回档 50% → −{drop50},撑得住吗?" }, - "breach_evidence": { - "none": "账上有一笔事件", - "more": ";另有 {extra} 笔", - "joiner": "; " + "tag_values": { + "price_note": "(现 {px}/均 {avg_cost})" }, - "candidate_comparison": { - "line": "本期“{top}”的讯号比“{others}”更强——这只反映本期哪个模式更明显,不代表哪条规矩更适合你。", - "joiner": "、" + "title": "复盘卡", + "trends": { + "better": "改善", + "flat": "持平", + "worse": "恶化" } } diff --git a/skills/fomo-kernel/copy/zh-TW.json b/skills/fomo-kernel/copy/zh-TW.json index 743d9ec4..d9eaa8fd 100644 --- a/skills/fomo-kernel/copy/zh-TW.json +++ b/skills/fomo-kernel/copy/zh-TW.json @@ -1,326 +1,397 @@ { - "language": "zh-TW", - "title": "復盤卡", - "private_badge": "復盤卡,只留在本機", - "public_badge": "可分享版,已移除金額、日期、標的與精確部位", - "public_custom_rule": "已承諾一條自訂流程規矩;內容只留在復盤卡。", - "demo_badge": "示範資料/演練,不是你的真實交易,也不寫入正式教練記憶", - "markdown_summary": { - "risk": "主要風險", - "next": "下次規則" + "account_gate": { + "cash_residual": "更新那一段的現金部位,就能看到含現金的帳戶級報酬。", + "chain_unavailable": "這個區間沒有任何一天算得出可用的帳戶淨值可以串成報酬。", + "default": "補齊現金錨點,就能看到含現金的帳戶級報酬。", + "mixed_trade_footprint": "交易紀錄裡只有一部分帶券商的現金金額,兩種口徑併不成同一條現金史。", + "negative_cash_rollback": "補上還沒有錨點的那個幣別,就能看到含現金的帳戶級報酬。", + "no_cash_anchor": "補一個目前的現金餘額,就能看到含現金的帳戶級報酬。" }, - "snapshot_numbers": "第一次持倉結構", - "snapshot_strength": "這份快照建立了什麼", - "snapshot_hole": "主要結構風險", - "sections": { - "performance": "相對績效", - "strength": "你做對的一件事", - "hole": "最大的行為漏洞", - "etf": "ETF 與組合結構", - "rule": "下次只改這一件" + "account_perf": { + "account_base": "帳戶級累積報酬為 {value}", + "annualized_suffix": ",年化報酬 {value}", + "cash_drag_suffix": ";與僅計持倉的差距 {value} 來自持有現金——這是觀察,不是對錯判定", + "holdings_only": "僅計持倉的累積報酬為 {value}。", + "terminator": "。" }, - "blocks": { - "performance": "這期的績效", - "trades": "關鍵交易 · 按金額排序", - "risks": "風險與問題", - "next": "下一步", - "summary": "總結" + "add_choices": { + "new_evidence": "有新的可驗證證據", + "planned_tranche": "事先規劃的分批", + "price_only": "只有價格下跌/想攤低成本", + "skip": "先跳過", + "valuation_change": "估值或賠率變了" }, - "period": { - "span": "復盤區間 {start} → {end}", - "as_of": "截至 {end}", - "vix": "VIX {value}" + "add_descriptions": { + "new_evidence": "必須補 claim 與 source,之後能回頭驗證。", + "planned_tranche": "進場前已定好節奏,價格下跌不是新增理由。", + "price_only": "沒有新事實,主要是想攤低成本或等回本。", + "skip": "先不定性,卡上只標未確認。", + "valuation_change": "判斷沒變,但價格讓賠率或安全邊際改變。" + }, + "alpha_interval": { + "below_negative": "年化 α 的區間包含負值,代表這段期間的選股優勢在統計上還不能視為穩定能力。", + "below_unreliable": "* 年化 α 統計上還不可信。", + "lead": "風險調整後 alpha{scope} 年化 {alpha}%,95% 區間為 {low}% 到 {high}%;定論強度以這個區間為準。", + "negative_caveat": "(區間包含負值,代表這段期間的選股優勢在統計上還不能視為穩定能力)", + "scope_suffix": "({market} 部位)", + "tile_sub": "95% 區間 {low}%~{high}%" + }, + "asked_because": { + "exit_notional": "它是你近期金額最大的出場之一", + "pnl_impact": "它是你本週影響損益最大的部位", + "position_cost": "它是你本週成本最大的部位" + }, + "benchmark_line": { + "beta_suffix": ";β {beta}", + "comparator_default": "市場大盤", + "line": "{subject}對 {comparator} 的超額報酬 {excess} 個百分點{beta}。", + "subject_default": "持倉", + "subject_market": "{market} 部位" + }, + "best_strength": { + "averaging_down": "你往下加碼 {count} 次,卻都守在自己的部位上限內、沒讓任何一檔越攤越重{example}", + "averaging_down_example": "(例:{ticker})", + "diversification": "{n} 檔分布在不同驅動因子,沒有全押在同一個故事上", + "exit_discipline": "該獲利了結時你不手軟:賣掉的賺錢單只有 {winner_early} 事後繼續漲,代表你不會「抱著賺錢的捨不得賣、結果回吐」", + "holding_period": "進出有一致的節奏:中位持有 {median_hold} 天,不是隨機亂買亂賣", + "no_signal": "這期沒有足夠強的正向訊號;先把注意力留給最大的洞。", + "position_sizing": "單筆部位有控制:押最重的一檔也只佔 {max_pct},沒把身家壓在一檔上" }, "block_missing": { "absolute_pnl": "本期算不出帳面損益:缺交易或估值基礎。", "annualized": "本期算不出年化報酬:現金流錨點不完整。", "annualized_prices": "本期算不出年化報酬:現價取不到,整體持倉沒有可用的估值基礎。", - "vs_market": "本期無法比對大盤:缺可用的基準序列。", - "vs_market_prices": "本期無法比對大盤:基準指數的價格取不到。", - "trades": "本期沒有可排序的標的層診斷。", - "trades_traded": "本期沒有可排序的標的層診斷;這期交易過:{tickers}。", + "annualized_reconciliation": "本期算不出年化報酬:匯入的交易紀錄推不出快照錨定的持倉,沒有可用的帳戶估值基礎。把你目前的持倉畫面給我,這個差異就能對清楚。", + "annualized_short_series": "本期算不出年化報酬:價格序列不足兩天,沒有可衡量的變化。", "risks": "本期沒有行為診斷:引擎沒有可評分的維度。", "rule": "這次沒有新的規矩承諾;下次復盤可再選一條。", - "rule_standing": "這期不改規矩;現行規矩維持:「{rule}」", - "rule_pending": "這期還沒選規矩;建議是:「{rule}」", "rule_diverged": "現行規矩仍是「{standing}」;這期最該處理的是另一件事,「{recommendation}」在你選定之前都只是建議。", + "rule_insufficient_data": "樣本仍短,這次不硬塞承諾;先把它當基線。", + "rule_pending": "這期還沒選規矩;建議是:「{rule}」", "rule_skip": "你這次選擇不設新承諾;下次仍可用同一份基線對帳。", "rule_snapshot": "這次開場檢查先保留結構基線,不強迫設定承諾。", + "rule_standing": "這期不改規矩;現行規矩維持:「{rule}」", "rule_structural": "這次是開場結構檢查:先立基線、不硬塞承諾。已平倉的交易還太少,還看不出你的賣出是不是比買進更傷你;補上對應的賣出紀錄就能回答這一題。", - "rule_insufficient_data": "樣本仍短,這次不硬塞承諾;先把它當基線。", "snapshot_unlock": "這次只看得到你手上有什麼,看不到你是怎麼走到這裡的。交易紀錄能回答它唯一答不了的那一題:你的賣出是不是比買進更傷你。", - "annualized_short_series": "本期算不出年化報酬:價格序列不足兩天,沒有可衡量的變化。", - "annualized_reconciliation": "本期算不出年化報酬:匯入的交易紀錄推不出快照錨定的持倉,沒有可用的帳戶估值基礎。把你目前的持倉畫面給我,這個差異就能對清楚。" - }, - "account_gate": { - "default": "補齊現金錨點,就能看到含現金的帳戶級報酬。", - "no_cash_anchor": "補一個目前的現金餘額,就能看到含現金的帳戶級報酬。", - "mixed_trade_footprint": "交易紀錄裡只有一部分帶券商的現金金額,兩種口徑併不成同一條現金史。", - "negative_cash_rollback": "補上還沒有錨點的那個幣別,就能看到含現金的帳戶級報酬。", - "cash_residual": "更新那一段的現金部位,就能看到含現金的帳戶級報酬。", - "chain_unavailable": "這個區間沒有任何一天算得出可用的帳戶淨值可以串成報酬。" - }, - "footnote_label": "資料備註", - "kpi": { - "pnl": "帳面總損益", - "pnl_sub": "已實現 {realized} · 未實現 {unrealized}", - "payoff": "已實現盈虧比", - "payoff_sub": "賺 {win} vs 賠 {loss}", - "excess": "相對大盤", - "excess_sub": "β {beta}", - "alpha": "年化 α", - "alpha_unreliable": "統計上還不可信", - "curve": "這期走勢", - "spark_caption": "高點 {peak} · 低點 {trough}" - }, - "etf_classification": { - "allocation": "配置型 ETF 已從單一股票集中度排除:{list}。", - "concentrated": "產業/主題/槓桿 ETF 仍算集中風險:{list}。" + "trades": "本期沒有可排序的標的層診斷。", + "trades_traded": "本期沒有可排序的標的層診斷;這期交易過:{tickers}。", + "vs_market": "本期無法比對大盤:缺可用的基準序列。", + "vs_market_prices": "本期無法比對大盤:基準指數的價格取不到。" }, - "dimensions": { - "exit_discipline": "出場紀律", - "position_sizing": "部位 sizing", - "diversification": "分散", - "holding_period": "持有時間", - "averaging_down": "加碼攤平", - "alpha_beta": "大盤與選股歸因", - "entry_style": "進場風格" + "blocks": { + "next": "下一步", + "performance": "這期的績效", + "risks": "風險與問題", + "summary": "總結", + "trades": "關鍵交易 · 按金額排序" }, - "public_patterns": { - "exit_discipline": "這次浮現的模式,關乎出場決策如何被安排與檢驗。", - "position_sizing": "這次浮現的模式,關乎風險如何分配到不同部位。", - "diversification": "這次浮現的模式,關乎組合是否代表彼此不同的底層風險。", - "holding_period": "這次浮現的模式,關乎持有時間是否對齊一致的決策時間軸。", - "holding_period_same_day": "所有已完成交易皆為當日進出,沒有隔夜持有。", - "averaging_down": "這次浮現的模式,關乎虧損部位的加碼如何受到界線約束。", - "alpha_beta": "這次浮現的模式,關乎相對大盤結果如何拆分為市場暴險與選擇。", - "entry_style": "這次浮現的模式,關乎進場時點與近期價格移動的關係。" + "breach_evidence": { + "joiner": "; ", + "more": ";另有 {extra} 筆", + "none": "帳上有一筆事件" }, - "rules": { - "exit_discipline": "出場前先寫下交易論述已完成或失效的事實;只因害怕回吐,不賣。", - "position_sizing": "單筆部位上限定死 {cap};超過就減,不新增。", - "diversification": "新增部位前先看是否仍是同一個驅動因子;同一注已過重,不新增。", - "holding_period": "進場時先標短線、波段或長線;出場只用同一時間框架的理由。", - "averaging_down": "往下加碼前寫出一個進場時不知道的新證據;寫不出,不加。" + "candidate_comparison": { + "joiner": "、", + "line": "本期「{top}」的訊號比「{others}」更強——這只反映本期哪個模式更明顯,不代表哪條規矩更適合你。" }, - "rule_grounding": { - "position_sizing": "本期實況:最大單一部位 {ticker} 佔 {pct}。", - "diversification": "本期實況:前幾大部位 {tickers} 合計佔 {pct}。", - "holding_period": "本期實況:{tickers} 同一檔既有短進短出、也有長抱。", - "averaging_down": "本期實況:你在虧損部位往下加碼 {count} 次,包括 {tickers}。" + "cash_lines": { + "amount_joiner": "、", + "anchored": "有餘額錨點的帳戶現金為 {cash}。", + "anchored_with_weight": "有餘額錨點的帳戶現金為 {cash},佔帳戶 {weight}。", + "by_currency": "有餘額錨點的帳戶現金(原幣):{amounts}。" }, - "rule_targets": { - "line": "本期會被它攔下的:{items}", - "count_unit": " 次", - "more_suffix": "、及其他 {n} 檔" + "condition_basis": { + "stem": "你設的是:「{criterion}」。它在量的東西可能變了:{note}。這條要怎麼處理?" }, - "rule_tradeoff": { - "sizing_vs_proven_edge": "上限管的是單筆爆掉,不跟「選股是真 edge」衝突。", - "sizing_vs_hypothesis": "上限管的是單筆爆掉;押對賽道還沒驗證,更不該靠加大單注押它。" + "condition_basis_choices": { + "keep": "維持原樣", + "revise_metric": "改成量別的", + "revise_threshold": "量的東西不變,把線挪一下", + "skip": "先放著" }, - "condition_state": { - "already_met": "這條線今天就已經跨過了,所以它現在是一個要做的決定,不是一個等著響的警報。", - "unmapped_no_threshold": "這條用你的原話記著;裡面沒有可以量的線,所以沒辦法幫你盯。", - "unmapped_no_baseline": "這條記下來了,但背後那個數字查不到,所以現在還沒在盯。", - "unmapped_no_adjudicator": "這條記成一個是或否的判斷,由你來下,不是被量出來的。", - "event_watch_started": "這條是一個是或否的判斷:從現在起會幫你盯著,有動靜就來問你——最後怎麼認定還是你說了算。" + "condition_basis_choices_descriptions": { + "keep": "這個變動不影響你原本在追的東西。", + "revise_metric": "這個數字已經量不到你的意思了——把條件重寫一次。", + "revise_threshold": "要量的還是同一個東西,只是數字該調。", + "skip": "先不回答;條件維持原本寫的樣子。" }, "condition_check": { - "then_now": "上次你承諾:「{rule}」——當時是 {then},現在是 {now}。", - "then_now_no_baseline": "上次你承諾:「{rule}」——當時查不到數字;現在是 {now}。", - "then_now_event": "上次你承諾:「{rule}」——從那時開始盯。最新狀況:{now}。", - "verdict_met": "已經過線了。", - "verdict_near_line": "離線很近了。", - "verdict_not_met": "還沒到線。", - "verdict_unknown": "這期沒有結論。", + "blind": "{criterion}——這期沒查到,現在是盯不到的狀態。", + "blind_with_reason": "{criterion}——這期沒查到({reason}),現在是盯不到的狀態。", "fact": "{criterion}——{value}({source},資料日 {as_of})。", - "fact_no_source": "{criterion}——{value}。", "fact_looks_wrong": "上面哪個數字看起來不對就說一聲,可以重新確認它的算法。", - "thesis_guard": "這是你說過會推翻 {ticker} 論點的那件事。", - "thesis_retired": "{criterion}——之後不再追蹤了:{ticker} 已經全數出清,這條要守的論點也結束了。", + "fact_no_source": "{criterion}——{value}。", + "note_basis_open": "它在量的東西可能變了({note}),這件事還沒處理;下次復盤會來問你。", "note_deferred": "這個已經過線了,下次復盤會來問你。", "note_deferred_event": "這可能就是你在盯的那件事,下次復盤會來問你。", "note_unanswered": "這個已經過線了,而你還沒表態;下次復盤會再問一次。", "note_unanswered_event": "這可能就是你在盯的那件事,而你還沒表態;下次復盤會再問一次。", - "note_basis_open": "它在量的東西可能變了({note}),這件事還沒處理;下次復盤會來問你。", - "blind": "{criterion}——這期沒查到,現在是盯不到的狀態。", - "blind_with_reason": "{criterion}——這期沒查到({reason}),現在是盯不到的狀態。", "summary_checked": "這期 {total} 條裡查了 {checked} 條。", "summary_open": "還沒結案、下次會再處理的事項:{deferred} 項。", - "summary_trimmed": "這張卡列出 {describable} 筆讀數裡的 {shown} 筆,其餘都在你的紀錄裡。" + "summary_trimmed": "這張卡列出 {describable} 筆讀數裡的 {shown} 筆,其餘都在你的紀錄裡。", + "then_now": "上次你承諾:「{rule}」——當時是 {then},現在是 {now}。", + "then_now_event": "上次你承諾:「{rule}」——從那時開始盯。最新狀況:{now}。", + "then_now_no_baseline": "上次你承諾:「{rule}」——當時查不到數字;現在是 {now}。", + "thesis_guard": "這是你說過會推翻 {ticker} 論點的那件事。", + "thesis_retired": "{criterion}——之後不再追蹤了:{ticker} 已經全數出清,這條要守的論點也結束了。", + "verdict_met": "已經過線了。", + "verdict_near_line": "離線很近了。", + "verdict_not_met": "還沒到線。", + "verdict_unknown": "這期沒有結論。" }, "condition_crossing": { + "event": "你設的是:「{criterion}」。這期查到:{evidence}。這件事發生了嗎?", "met": "你設的是:「{criterion}」。這期查到:{evidence}。這樣算跨過去了嗎?", - "near_line": "你設的是:「{criterion}」。這期查到:{evidence}——離線很近。這樣算跨過去了嗎?", - "event": "你設的是:「{criterion}」。這期查到:{evidence}。這件事發生了嗎?" + "near_line": "你設的是:「{criterion}」。這期查到:{evidence}——離線很近。這樣算跨過去了嗎?" }, "condition_crossing_choices": { "confirmed": "算,就當它跨過去了", - "overridden": "不算,別當它跨過去", - "yes": "有,發生了", "no": "沒有,還沒發生", - "skip": "先放著" + "overridden": "不算,別當它跨過去", + "skip": "先放著", + "yes": "有,發生了" }, "condition_crossing_choices_descriptions": { "confirmed": "這個讀數成立,你寫的規矩就該生效。", - "overridden": "這個讀數量到的不是你原本的意思——說一下為什麼。", - "yes": "你在盯的那件事已經發生了。", "no": "還沒發生,繼續盯。", - "skip": "先不回答;讀數本身還是會被記下來。" - }, - "condition_basis": { - "stem": "你設的是:「{criterion}」。它在量的東西可能變了:{note}。這條要怎麼處理?" + "overridden": "這個讀數量到的不是你原本的意思——說一下為什麼。", + "skip": "先不回答;讀數本身還是會被記下來。", + "yes": "你在盯的那件事已經發生了。" }, - "condition_basis_choices": { - "revise_threshold": "量的東西不變,把線挪一下", - "revise_metric": "改成量別的", - "keep": "維持原樣", - "skip": "先放著" + "condition_state": { + "already_met": "這條線今天就已經跨過了,所以它現在是一個要做的決定,不是一個等著響的警報。", + "event_watch_started": "這條是一個是或否的判斷:從現在起會幫你盯著,有動靜就來問你——最後怎麼認定還是你說了算。", + "unmapped_no_adjudicator": "這條記成一個是或否的判斷,由你來下,不是被量出來的。", + "unmapped_no_baseline": "這條記下來了,但背後那個數字查不到,所以現在還沒在盯。", + "unmapped_no_threshold": "這條用你的原話記著;裡面沒有可以量的線,所以沒辦法幫你盯。" }, - "condition_basis_choices_descriptions": { - "revise_threshold": "要量的還是同一個東西,只是數字該調。", - "revise_metric": "這個數字已經量不到你的意思了——把條件重寫一次。", - "keep": "這個變動不影響你原本在追的東西。", - "skip": "先不回答;條件維持原本寫的樣子。" + "currency_note": { + "cached": "顯示換算沿用上次對帳匯率。", + "cached_dated": "顯示換算沿用上次對帳匯率(截至 {when})。", + "no_rate": "找不到可靠的 {currency} 顯示匯率;金額保留原幣,不做猜測換算。", + "portfolio_fx_gap": "至少一個持倉幣別缺少可靠匯率;金額保留原幣,不把近似聚合值當成精確換算。" }, - "patterns_panel": { - "label": "一個模式,還沒下判斷(不用回答)", - "sold_winner_early": "這期賣掉的 {total} 筆有 {early} 筆賣完還漲,最明顯是 {tickers}。" + "decision_entries": { + "line": "{ticker}:{label}。這個判斷與證據邊界已保存,供下次對帳。", + "ticker_default": "position" }, - "exit_consistency": { - "question": "這是你出場規則裡一致的做法、情緒反應,還是外部限制?" + "demo_badge": "示範資料/演練,不是你的真實交易,也不寫入正式教練記憶", + "dimensions": { + "alpha_beta": "大盤與選股歸因", + "averaging_down": "加碼攤平", + "diversification": "分散", + "entry_style": "進場風格", + "exit_discipline": "出場紀律", + "holding_period": "持有時間", + "position_sizing": "部位 sizing" }, - "add_choices": { - "planned_tranche": "事先規劃的分批", - "new_evidence": "有新的可驗證證據", - "valuation_change": "估值或賠率變了", - "price_only": "只有價格下跌/想攤低成本", - "skip": "先跳過" + "due_choices": { + "falsified": "看錯了", + "modified": "部分對,要調", + "skip": "先跳過", + "still_valid": "還成立" }, - "headline_motive_choices": { - "deliberate_plan": "事先規劃", - "emotional_reaction": "情緒反應", - "external_constraint": "外部限制" + "due_descriptions": { + "falsified": "真的判斷錯誤,記進教訓。", + "modified": "理由部分成立,需要修正。", + "skip": "不算已回答,下次復盤同一關會再出現。", + "still_valid": "理由仍成立,賣早也是紀律。" }, - "initial_thesis_choices": { - "planned_entry": "進場前就有明確論點", - "momentum_follow": "追了動能/FOMO", - "external_call": "別人推薦的", - "no_clear_thesis": "沒有明確論點", - "skip": "先跳過" + "etf_classification": { + "allocation": "配置型 ETF 已從單一股票集中度排除:{list}。", + "concentrated": "產業/主題/槓桿 ETF 仍算集中風險:{list}。" }, "exit_choices": { "full": { - "price_target": "到價了", - "thesis_broken": "原本判斷失效", - "swap": "換到更好的標的", "anxiety": "想先落袋", "other": "其他理由", - "skip": "先不回答" + "price_target": "到價了", + "skip": "先不回答", + "swap": "換到更好的標的", + "thesis_broken": "原本判斷失效" }, "reduce": { - "price_target": "到了減碼點", - "thesis_broken": "信心動搖", - "swap": "換到更好的標的", "anxiety": "想先落袋一部分", "other": "其他理由", - "skip": "先不回答" + "price_target": "到了減碼點", + "skip": "先不回答", + "swap": "換到更好的標的", + "thesis_broken": "信心動搖" } }, - "honesty": { - "snapshot_scope": "交易歷史維度——攤平、出場紀律、持有行為、勝率、盈虧比、alpha 與歷史動機——不在這次持倉快照的評分範圍內。", - "alpha_credibility": "Alpha 的樣本或統計強度不足,不能當成穩定能力。", - "sector_attribution": "部分標的缺板塊基準,賽道與選股拆帳不完整。", - "unclassified_drivers": "部分標的尚未分類,組合集中度可能被低估。", - "price_source": "這期的現價不是引擎自己抓到的:不是來自明確標示的外部資料源與截止日,就是根本取不到,卡上會說明是哪一種。", - "price_plausibility": "有供給的收盤價與該檔最近一次實際成交價落差過大,這個來源在採信前應該重新核實。", - "unrealized_coverage": "部分持倉缺現價,未實現損益不是完整帳面。", - "sizing_coverage": "部分持倉既無現價也無成本基礎,部位大小與過重判斷僅涵蓋能估值的持倉。", - "orphan_sells": "有賣出缺少已知建倉,相關已實現損益未納入。", - "unpriced_exits": "有部位離開紀錄時沒有成交價,這些出場未納入勝率、盈虧比與出場紀律。", - "currency_mix": "組合含多幣別;聚合數字使用共同幣別,部分匯率可能為近似。", - "cash_reliability": "現金餘額缺完整錨點,含現金的帳戶判讀有限。", - "acct_perf_basis": "帳戶績效建立在近似的地基上——部分錨點、成本平線計價、匯率近似、用量×價估算的交易現金金額,或一段完全沒有存提款紀錄的期間——需保留不確定性。", - "accounting_reconciliation": "匯入交易未能重現快照錨定的目前持倉,因此這次不評分部位大小、分散與未實現損益。", - "snapshot_reconciliation": "這份持倉宣告已與帳本推導持倉對帳;一致即標記為已對帳,若有差異則以明確的調整事件保留紀錄,並改以較新宣告為記帳錨點,不猜測差異原因。", - "etf_metadata": "ETF 費用率或 tracking error 資料不完整,本卡沒有把缺值猜成零。", - "prior_commitment_breach": "這條規矩本期已經記到一筆違規事件,在這次復盤結算之前就已經發生。" - }, - "instrument_tags": { - "suspected_averaging_down_losing": "✗疑似凹單:只在虧損加碼 {n_adds} 次、現虧 {cur_pct}{price_note}(待你確認交易論述)", - "suspected_averaging_down_recovered": "⚠疑似凹單(現賺):只在虧損加碼 {n_adds} 次——賺回來像運氣,不是紀律", - "adds_pending_confirmation": "?加碼 {n_adds} 次待確認:是定投還是凹單,要你定", - "suspected_dca": "✓疑似定投:漲跌都買/規律 {n_adds} 次,不是凹單", - "deep_underwater": "✗套牢:{cur_pct}{price_note} 還抱著沒處理", - "too_heavy": "⚠押太重:佔組合 {wpct_pct}", - "disciplined_hold": "✓紀律持有:賺 {cur_pct}{price_note}", - "roughly_neutral": "— 大致中性" + "exit_consistency": { + "question": "這是你出場規則裡一致的做法、情緒反應,還是外部限制?" }, - "stress_test": { - "labels": { - "ai_thematic": "AI 概念股(跨板塊)", - "sector": "「{sector}」板塊", - "single_ticker": "單檔 {ticker}" - }, - "line": "你 {label} 暴險約 {exposure}(佔 {pct});回檔 30% → 帳面 −{drop30}、回檔 50% → −{drop50},撐得住嗎?" + "exit_descriptions": { + "anxiety": "主要是怕回吐,所以先鎖住全部或部分成果。", + "other": "以上都不符合,用自己的話留下一句。", + "price_target": "原先設定的目標或減碼條件已經完成。", + "skip": "保存為已略過,之後不再追問這筆的賣出理由。", + "swap": "資金改放到另一個標的或用途。", + "thesis_broken": "原本判斷失效,或信心因新事實而下降。" }, - "prescription_kinds": { - "amplify": "揚長", - "amplify_hypothesis": "揚長(假設,待驗證)", - "selection_inconclusive": "選股:資料不足以判定", - "outsource": "外包短板(漸進)", - "cut_loss": "砍損耗" + "exit_entries": { + "action_full": "清倉", + "action_partial": "減倉", + "line": "{ticker}:你把這次{action}記為「{label}」。這裡只保存當時的理由,尚未判斷決策結果。", + "ticker_default": "這筆部位" }, - "prescription_texts": { - "amplify_hypothesis": "你贏大盤 {excess_pp}pp 裡,押對賽道佔 {alloc_pp}pp(拆帳)。那還只是假設,不是已證實的 edge——押對 AI 也可能只是站到風口,要靠下次的賽道判斷才驗得出來。", - "selection_inconclusive": "拆帳看,板塊內選股貢獻 {sel_pp}pp(描述性、這數字站得住);但 {t_note},統計上還分不出選股是本事還是運氣。", - "selection_inconclusive_t_wide": "α 的 95% 區間還太寬(t={t})", - "selection_inconclusive_t_unstable": "α 統計量還算不穩", - "outsource_selection": "扣掉賽道,你板塊內選股貢獻 {sel_pp}pp、α 統計上站得住地差。這不是說『別選股』(你享受它、ETF 也會錯過妖股),而是可以撥一部分資金被動化托底,選股當衛星。(流程觀察,非標的建議)", - "amplify_selection_edge": "扣掉賽道紅利,你板塊內選股仍貢獻 {sel_pp}pp、α 統計上站得住——這是真 edge。", - "cut_averaging_down": "虧損中加碼 {count} 次是你操盤損耗的大宗——這是最該先砍的純扣分動作。", - "cut_oversize": "最大一筆 {ticker} 佔 {max_pct_pct},單一押注過重。" + "exit_followup": { + "action_full": "清倉", + "action_reduce": "減倉", + "as_of": "(截至 {as_of})", + "backlog": "歷史出場 backlog 尚有 {count} 筆未複核(清倉 {full}、減倉 {reduce})", + "backlog_priced": " 有現價可回看的 {priced} 筆,出場後平均走勢為 {avg};其中 {rose} 筆後續上漲。", + "backlog_span": ",期間 {first} 到 {last}", + "backlog_top": ";最常出現:{top}", + "check": "{ticker},{checkpoint} 天複核:{label}。", + "focus": "Backlog 優先回看:{ticker},{exit_date} {action}。", + "focus_idle": " 賣後資金閒置,原標的同期 {orig}{when}。", + "focus_needs_prices": " {missing} 缺凍結現價{when},不判結果。", + "focus_orig": " 原標的後續 {orig}{when}。", + "focus_swap": " 原標的後續 {orig}{when};換入標的同期 {swap};swap 淨差 {net}。", + "frozen_lead": " 以 {as_of} 凍結現價計,", + "frozen_lead_absent": " ", + "frozen_note": "(以 {as_of} 凍結現價計)", + "idle": " 賣後資金閒置,原標的同期 {orig}{when}。", + "needs_prices": " {missing} 缺現價{when},本期不判結果。", + "note": " 註記:「{note}」。", + "position_fallback": "這筆部位", + "replacement_fallback": "換入標的", + "sentence_end": "。", + "swap": "{lead}原標的後續 {orig};{swaps}同期 {swap};swap 淨差 {net}。" }, - "due_choices": { - "still_valid": "還成立", - "modified": "部分對,要調", - "falsified": "看錯了", + "footnote_label": "資料備註", + "generic_choices": { + "deliberate_plan": "事先規劃", + "emotional_reaction": "情緒反應", + "external_constraint": "外部限制", "skip": "先跳過" }, - "rule_breach_choices": { - "keep_tracking": "這次守不住", - "revise_rule": "規矩需要修訂", - "exception": "這次是正當例外" + "generic_descriptions": { + "deliberate_plan": "行動遵循交易前就存在的規則。", + "emotional_reaction": "恐懼、後悔或急迫感主導了行動。", + "external_constraint": "資金、稅務或其他限制主導了行動。", + "skip": "這次先不替動機下定論。" }, - "rule_breach_decisions": { - "keep_tracking": "這次沒守住,繼續追蹤", - "revise_rule": "標記為需要修訂", - "exception": "記為正當例外" + "headline_motive_choices": { + "deliberate_plan": "事先規劃", + "emotional_reaction": "情緒反應", + "external_constraint": "外部限制" + }, + "hole_lines": { + "averaging_down": "你有 {count} 次在虧損倉往下加碼({tickers}),其中 {breach} 次加碼當下佔成本 >25%", + "diversification": "你持有 {n} 檔看似分散,但 AI capex 暴險 {ai_pct}、最大板塊「{max_sector}」{max_sector_pct}、top3 {top3}——同一個驅動因子", + "exit_forward": "{n_rt} 筆決策賣出({n_scored} 有 fwd、{n_trunc} 截斷)中 {early_rate} 在 {n_fwd} 天後更高、平均續漲 {avg_forgone}{winner_early}", + "exit_holding": "賺錢抱 {hold_win} 天 / 賠錢抱 {hold_lose} 天(處置缺口 {disp_gap})", + "exit_winner_early": ";賣掉賺錢的有 {winner_early} 續漲(賣太早)", + "holding_base": "你持有時間 {min}~{max} 天、中位 {median_hold} 天", + "holding_consistent": "{base}(中位 {median_hold} 天 = 你的主框架;同檔框架大致一致)", + "holding_inconsistent": "{base};其中 {n_incon}/{n_multi} 檔同一檔又當沖又長抱({tickers})——同檔沒有一致框架", + "holding_no_data": "暫無已實現 round-trip,持有時間統計待生成(只看買進尚未賣出的不納入)", + "holding_same_day": "所有已完成交易皆為當日進出,沒有隔夜持有", + "joiner": ";", + "position_sizing": "你最大一筆 {max_ticker} 佔 {max_pct},其餘平均 {avg_pct}", + "ticker_joiner": ", " + }, + "honesty": { + "accounting_reconciliation": "匯入交易未能重現快照錨定的目前持倉,因此這次不評分部位大小、分散與未實現損益。", + "acct_perf_basis": "帳戶績效建立在近似的地基上——部分錨點、成本平線計價、匯率近似、用量×價估算的交易現金金額,或一段完全沒有存提款紀錄的期間——需保留不確定性。", + "alpha_credibility": "Alpha 的樣本或統計強度不足,不能當成穩定能力。", + "cash_reliability": "現金餘額缺完整錨點,含現金的帳戶判讀有限。", + "currency_mix": "組合含多幣別;聚合數字使用共同幣別,部分匯率可能為近似。", + "etf_metadata": "ETF 費用率或 tracking error 資料不完整,本卡沒有把缺值猜成零。", + "orphan_sells": "有賣出缺少已知建倉,相關已實現損益未納入。", + "price_plausibility": "有供給的收盤價與該檔最近一次實際成交價落差過大,這個來源在採信前應該重新核實。", + "price_source": "這期的現價不是引擎自己抓到的:不是來自明確標示的外部資料源與截止日,就是根本取不到,卡上會說明是哪一種。", + "prior_commitment_breach": "這條規矩本期已經記到一筆違規事件,在這次復盤結算之前就已經發生。", + "sector_attribution": "部分標的缺板塊基準,賽道與選股拆帳不完整。", + "sizing_coverage": "部分持倉既無現價也無成本基礎,部位大小與過重判斷僅涵蓋能估值的持倉。", + "snapshot_reconciliation": "這份持倉宣告已與帳本推導持倉對帳;一致即標記為已對帳,若有差異則以明確的調整事件保留紀錄,並改以較新宣告為記帳錨點,不猜測差異原因。", + "snapshot_scope": "交易歷史維度——攤平、出場紀律、持有行為、勝率、盈虧比、alpha 與歷史動機——不在這次持倉快照的評分範圍內。", + "unclassified_drivers": "部分標的尚未分類,組合集中度可能被低估。", + "unpriced_exits": "有部位離開紀錄時沒有成交價,這些出場未納入勝率、盈虧比與出場紀律。", + "unrealized_coverage": "部分持倉缺現價,未實現損益不是完整帳面。" + }, + "horizon_entries": { + "exit_too_fast": "{ticker}:{voice}的 thesis 時間軸是「{horizon}」,{days} 天後就出場;這是時間軸不一致,不替動機下定論。", + "held_too_long": "{ticker}:{voice}的 thesis 時間軸是「{horizon}」,持有 {days} 天後仍未結束;時間軸已漂移,仍需釐清。", + "ticker_default": "這筆部位", + "voice_inferred": "原先推測", + "voice_recorded": "已記錄" }, "horizons": { - "weeks": "週", "quarters": "季", + "weeks": "週", "years": "年" }, - "problem_keys": { - "avgdown_breach": "攤平越過部位界線", - "sell_winner_early": "賣出贏家的機會成本", - "oversize": "單一部位過重", - "concentration": "同一驅動因子集中", - "hold_inconsistency": "持有時間軸混用", - "exit_anxiety": "焦慮驅動出場", - "fomo_entry": "FOMO 進場", - "horizon_break": "交易論述時間軸破戒" + "initial_thesis_choices": { + "external_call": "別人推薦的", + "momentum_follow": "追了動能/FOMO", + "no_clear_thesis": "沒有明確論點", + "planned_entry": "進場前就有明確論點", + "skip": "先跳過" }, - "trends": { - "worse": "惡化", - "flat": "持平", - "better": "改善" + "initial_thesis_descriptions": { + "external_call": "KOL、朋友或研究報告推薦的。", + "momentum_follow": "追了價格動能或 FOMO。", + "no_clear_thesis": "在沒有明確論點下持有。", + "planned_entry": "進場前就有明確的論點。", + "skip": "這次先不替進場動機下定論。" + }, + "instrument_tags": { + "adds_pending_confirmation": "?加碼 {n_adds} 次待確認:是定投還是凹單,要你定", + "deep_underwater": "✗套牢:{cur_pct}{price_note} 還抱著沒處理", + "disciplined_hold": "✓紀律持有:賺 {cur_pct}{price_note}", + "roughly_neutral": "— 大致中性", + "suspected_averaging_down_losing": "✗疑似凹單:只在虧損加碼 {n_adds} 次、現虧 {cur_pct}{price_note}(待你確認交易論述)", + "suspected_averaging_down_recovered": "⚠疑似凹單(現賺):只在虧損加碼 {n_adds} 次——賺回來像運氣,不是紀律", + "suspected_dca": "✓疑似定投:漲跌都買/規律 {n_adds} 次,不是凹單", + "too_heavy": "⚠押太重:佔組合 {wpct_pct}" + }, + "kpi": { + "alpha": "年化 α", + "alpha_unreliable": "統計上還不可信", + "curve": "這期走勢", + "excess": "相對大盤", + "excess_sub": "β {beta}", + "payoff": "已實現盈虧比", + "payoff_sub": "賺 {win} vs 賠 {loss}", + "pnl": "帳面總損益", + "pnl_sub": "已實現 {realized} · 未實現 {unrealized}", + "spark_caption": "高點 {peak} · 低點 {trough}" + }, + "language": "zh-TW", + "markdown_summary": { + "next": "下次規則", + "risk": "主要風險" + }, + "motive_entries": { + "context": "引擎脈絡:{fact}。", + "recorded": "「{subject}」的動機記為:{label}。", + "saved": "這個選項已保存,供後續復盤對帳。", + "subject_default": "這次浮現的行為", + "terminator": "。" + }, + "patterns_panel": { + "label": "一個模式,還沒下判斷(不用回答)", + "sold_winner_early": "這期賣掉的 {total} 筆有 {early} 筆賣完還漲,最明顯是 {tickers}。" + }, + "payoff_lines": { + "drag_plain": "最大已實現拖累是 {ticker};拿掉它後盈虧比會是 {after}。", + "drag_with_amount": "最大已實現拖累是 {ticker},淨影響 {drag};拿掉它後盈虧比會是 {after}。", + "original_currency": "已實現盈虧比 {payoff};平均盈虧金額因顯示匯率缺失而保留原幣。", + "sub_wrap": "({sub})" + }, + "period": { + "as_of": "截至 {end}", + "span": "復盤區間 {start} → {end}", + "vix": "VIX {value}" }, "pnl_lines": { "display": { - "total": "帳面總損益 {total},其中已實現 {realized}、未實現 {unrealized}。", "realized_only": "已實現損益 {realized};目前未實現損益未評分。", + "total": "帳面總損益 {total},其中已實現 {realized}、未實現 {unrealized}。", "unrealized_only": "未實現損益 {unrealized};已實現損益無法取得。" }, "original": { @@ -329,258 +400,200 @@ "unrealized_only": "{currency} 未實現損益 {unrealized}。" } }, - "alpha_interval": { - "scope_suffix": "({market} 部位)", - "negative_caveat": "(區間包含負值,代表這段期間的選股優勢在統計上還不能視為穩定能力)", - "lead": "風險調整後 alpha{scope} 年化 {alpha}%,95% 區間為 {low}% 到 {high}%;定論強度以這個區間為準。", - "tile_sub": "95% 區間 {low}%~{high}%", - "below_unreliable": "* 年化 α 統計上還不可信。", - "below_negative": "年化 α 的區間包含負值,代表這段期間的選股優勢在統計上還不能視為穩定能力。" - }, - "best_strength": { - "no_signal": "這期沒有足夠強的正向訊號;先把注意力留給最大的洞。", - "exit_discipline": "該獲利了結時你不手軟:賣掉的賺錢單只有 {winner_early} 事後繼續漲,代表你不會「抱著賺錢的捨不得賣、結果回吐」", - "position_sizing": "單筆部位有控制:押最重的一檔也只佔 {max_pct},沒把身家壓在一檔上", - "averaging_down": "你往下加碼 {count} 次,卻都守在自己的部位上限內、沒讓任何一檔越攤越重{example}", - "averaging_down_example": "(例:{ticker})", - "diversification": "{n} 檔分布在不同驅動因子,沒有全押在同一個故事上", - "holding_period": "進出有一致的節奏:中位持有 {median_hold} 天,不是隨機亂買亂賣" + "prescription_kinds": { + "amplify": "揚長", + "amplify_hypothesis": "揚長(假設,待驗證)", + "cut_loss": "砍損耗", + "outsource": "外包短板(漸進)", + "selection_inconclusive": "選股:資料不足以判定" }, - "reconciliation": { - "statement": "上次你承諾:「{rule}」。", - "statement_with_metric": "上次你承諾:「{rule}」——追蹤的數字當時 {then},這次 {now}。" + "prescription_texts": { + "amplify_hypothesis": "你贏大盤 {excess_pp}pp 裡,押對賽道佔 {alloc_pp}pp(拆帳)。那還只是假設,不是已證實的 edge——押對 AI 也可能只是站到風口,要靠下次的賽道判斷才驗得出來。", + "amplify_selection_edge": "扣掉賽道紅利,你板塊內選股仍貢獻 {sel_pp}pp、α 統計上站得住——這是真 edge。", + "cut_averaging_down": "虧損中加碼 {count} 次是你操盤損耗的大宗——這是最該先砍的純扣分動作。", + "cut_oversize": "最大一筆 {ticker} 佔 {max_pct_pct},單一押注過重。", + "outsource_selection": "扣掉賽道,你板塊內選股貢獻 {sel_pp}pp、α 統計上站得住地差。這不是說『別選股』(你享受它、ETF 也會錯過妖股),而是可以撥一部分資金被動化托底,選股當衛星。(流程觀察,非標的建議)", + "selection_inconclusive": "拆帳看,板塊內選股貢獻 {sel_pp}pp(描述性、這數字站得住);但 {t_note},統計上還分不出選股是本事還是運氣。", + "selection_inconclusive_t_unstable": "α 統計量還算不穩", + "selection_inconclusive_t_wide": "α 的 95% 區間還太寬(t={t})" }, - "snapshot": { - "overview": { - "subject_with_count": "使用者提供的 {positions} 個持倉", - "subject_generic": "使用者提供的持倉", - "opening": "這是針對{subject}的開場組合檢查。", - "opening_as_of": "這是針對{subject}的開場組合檢查,快照截至 {as_of}。", - "valuation_market_value": "結構權重採使用者提供的市值口徑。", - "valuation_cost": "結構權重採使用者提供的成本口徑。", - "valuation_unavailable": "目前沒有可靠估值口徑,因此不評分依賴權重的組合結構。", - "missing_avg_cost": "以下持倉缺少平均成本:{tickers}。", - "missing_fx": "以下幣別缺少可靠匯率:{currencies}。" - }, - "strength": { - "weighted": "已用你提供的持倉建立組合結構基線,並據此評分權重;缺少的輸入維持明示,不用推測補齊。", - "baseline": "已用使用者提供的持倉建立結構基線;依賴權重的優勢暫不評分。" - }, - "holes": { - "leading_risk": "現有快照的主要結構風險是「{label}」。", - "clean_structure": "這次持倉快照沒有觸發集中度或分散度的結構風險。", - "no_weights": "這次開場檢查只建立結構基線;無法取得的權重不會被當成低風險。" - } + "private_badge": "復盤卡,只留在本機", + "problem_keys": { + "avgdown_breach": "攤平越過部位界線", + "concentration": "同一驅動因子集中", + "exit_anxiety": "焦慮驅動出場", + "fomo_entry": "FOMO 進場", + "hold_inconsistency": "持有時間軸混用", + "horizon_break": "交易論述時間軸破戒", + "oversize": "單一部位過重", + "sell_winner_early": "賣出贏家的機會成本" }, "problems": { - "trend_line": "{name}:近期 {recent} 次,前期 {prev} 次({trend})。", "breach_decision": "規矩「{rule}」:{label}。", "breach_note": " 註記:「{note}」。", - "rule_held": "規矩「{rule}」在最近一個可觀測週期守住了。" + "rule_held": "規矩「{rule}」在最近一個可觀測週期守住了。", + "trend_line": "{name}:近期 {recent} 次,前期 {prev} 次({trend})。" }, - "exit_followup": { - "position_fallback": "這筆部位", - "check": "{ticker},{checkpoint} 天複核:{label}。", - "note": " 註記:「{note}」。", - "as_of": "(截至 {as_of})", - "needs_prices": " {missing} 缺現價{when},本期不判結果。", - "replacement_fallback": "換入標的", - "frozen_lead": " 以 {as_of} 凍結現價計,", - "frozen_lead_absent": " ", - "swap": "{lead}原標的後續 {orig};{swaps}同期 {swap};swap 淨差 {net}。", - "frozen_note": "(以 {as_of} 凍結現價計)", - "idle": " 賣後資金閒置,原標的同期 {orig}{when}。", - "backlog": "歷史出場 backlog 尚有 {count} 筆未複核(清倉 {full}、減倉 {reduce})", - "backlog_span": ",期間 {first} 到 {last}", - "backlog_top": ";最常出現:{top}", - "sentence_end": "。", - "backlog_priced": " 有現價可回看的 {priced} 筆,出場後平均走勢為 {avg};其中 {rose} 筆後續上漲。", - "action_full": "清倉", - "action_reduce": "減倉", - "focus": "Backlog 優先回看:{ticker},{exit_date} {action}。", - "focus_needs_prices": " {missing} 缺凍結現價{when},不判結果。", - "focus_swap": " 原標的後續 {orig}{when};換入標的同期 {swap};swap 淨差 {net}。", - "focus_idle": " 賣後資金閒置,原標的同期 {orig}{when}。", - "focus_orig": " 原標的後續 {orig}{when}。" + "public_badge": "可分享版,已移除金額、日期、標的與精確部位", + "public_band": { + "high": "高", + "low": "低", + "moderate": "中", + "very_high": "很高" }, - "account_perf": { - "holdings_only": "僅計持倉的累積報酬為 {value}。", - "account_base": "帳戶級累積報酬為 {value}", - "annualized_suffix": ",年化報酬 {value}", - "cash_drag_suffix": ";與僅計持倉的差距 {value} 來自持有現金——這是觀察,不是對錯判定", - "terminator": "。" + "public_custom_rule": "已承諾一條自訂流程規矩;內容只留在復盤卡。", + "public_mirror": { + "behavioral": "這次復盤在「{dim}」看見{severity}程度的行為壓力。", + "behavioral_dim_default": "主要行為維度", + "no_holes": "這次可用歷史不足以排序出主要行為模式。", + "snapshot_baseline": "這次開場組合檢查只建立結構基線;交易歷史行為維度維持未評分。", + "structural": "這次開場組合檢查把「{dim}」列為主要結構風險。", + "structural_dim_default": "組合結構", + "structural_with_severity": "這次開場組合檢查把「{dim}」列為主要結構風險,風險壓力為{severity}。", + "structure": "配置型 ETF 與單一標的風險分開計算;產業、主題與槓桿 ETF 仍保留集中風險。" }, - "currency_note": { - "cached_dated": "顯示換算沿用上次對帳匯率(截至 {when})。", - "cached": "顯示換算沿用上次對帳匯率。", - "portfolio_fx_gap": "至少一個持倉幣別缺少可靠匯率;金額保留原幣,不把近似聚合值當成精確換算。", - "no_rate": "找不到可靠的 {currency} 顯示匯率;金額保留原幣,不做猜測換算。" + "public_patterns": { + "alpha_beta": "這次浮現的模式,關乎相對大盤結果如何拆分為市場暴險與選擇。", + "averaging_down": "這次浮現的模式,關乎虧損部位的加碼如何受到界線約束。", + "diversification": "這次浮現的模式,關乎組合是否代表彼此不同的底層風險。", + "entry_style": "這次浮現的模式,關乎進場時點與近期價格移動的關係。", + "exit_discipline": "這次浮現的模式,關乎出場決策如何被安排與檢驗。", + "holding_period": "這次浮現的模式,關乎持有時間是否對齊一致的決策時間軸。", + "holding_period_same_day": "所有已完成交易皆為當日進出,沒有隔夜持有。", + "position_sizing": "這次浮現的模式,關乎風險如何分配到不同部位。" }, - "benchmark_line": { - "line": "{subject}對 {comparator} 的超額報酬 {excess} 個百分點{beta}。", - "subject_market": "{market} 部位", - "subject_default": "持倉", - "comparator_default": "市場大盤", - "beta_suffix": ";β {beta}" + "public_performance": { + "line": "{subject}:相對各自市場大盤 {excess} 個百分點;β {beta}。", + "subject_default": "可比較部位" }, - "split_lines": { - "line": "{subject}贏大盤的 {excess} 個百分點拆為:市場/賽道配置 {allocation} 個百分點、標的選擇 {selection} 個百分點。", - "subject_market": "{market} ", - "subject_default": "" + "rationale_refresh": { + "stem": "你在 {stated_at} 說你持有 {ticker} 是因為:「{statement}」。現在還是這個理由嗎?" }, - "decision_entries": { - "line": "{ticker}:{label}。這個判斷與證據邊界已保存,供下次對帳。", - "ticker_default": "position" + "rationale_refresh_choices": { + "changed": "理由變了", + "same": "還是同一個理由", + "skip": "先不回答" }, - "motive_entries": { - "subject_default": "這次浮現的行為", - "context": "引擎脈絡:{fact}。", - "recorded": "「{subject}」的動機記為:{label}。", - "saved": "這個選項已保存,供後續復盤對帳。", - "terminator": "。" + "rationale_refresh_choices_descriptions": { + "changed": "寫下現在的理由。舊的字句會留著,不是被覆蓋。", + "same": "以今天的日期記下你確認過。你原本的字句原封不動保留。", + "skip": "先不回答;你先前說的仍然有效。" }, - "exit_entries": { - "line": "{ticker}:你把這次{action}記為「{label}」。這裡只保存當時的理由,尚未判斷決策結果。", - "ticker_default": "這筆部位", - "action_full": "清倉", - "action_partial": "減倉" + "reconciliation": { + "statement": "上次你承諾:「{rule}」。", + "statement_with_metric": "上次你承諾:「{rule}」——追蹤的數字當時 {then},這次 {now}。" }, "review_milestone": { "line": "開始這次復盤時,你已有 {completed} 次完成紀錄。", - "noun_one": "", - "noun_many": "" + "noun_many": "", + "noun_one": "" }, - "horizon_entries": { - "ticker_default": "這筆部位", - "voice_inferred": "原先推測", - "voice_recorded": "已記錄", - "exit_too_fast": "{ticker}:{voice}的 thesis 時間軸是「{horizon}」,{days} 天後就出場;這是時間軸不一致,不替動機下定論。", - "held_too_long": "{ticker}:{voice}的 thesis 時間軸是「{horizon}」,持有 {days} 天後仍未結束;時間軸已漂移,仍需釐清。" + "rule_breach_choices": { + "exception": "這次是正當例外", + "keep_tracking": "這次守不住", + "revise_rule": "規矩需要修訂" }, - "public_band": { - "low": "低", - "moderate": "中", - "high": "高", - "very_high": "很高" + "rule_breach_decisions": { + "exception": "記為正當例外", + "keep_tracking": "這次沒守住,繼續追蹤", + "revise_rule": "標記為需要修訂" }, - "public_performance": { - "line": "{subject}:相對各自市場大盤 {excess} 個百分點;β {beta}。", - "subject_default": "可比較部位" + "rule_breach_descriptions": { + "exception": "這次有正當例外;在 note 留下理由,事件仍保留在帳上。", + "keep_tracking": "規矩合理,但這次沒有守住;照實記錄並繼續追。", + "revise_rule": "規矩本身不合理;在 note 簡述為何要改,收尾時用唯一 commitment 寫替代規矩。" }, - "payoff_lines": { - "sub_wrap": "({sub})", - "original_currency": "已實現盈虧比 {payoff};平均盈虧金額因顯示匯率缺失而保留原幣。", - "drag_with_amount": "最大已實現拖累是 {ticker},淨影響 {drag};拿掉它後盈虧比會是 {after}。", - "drag_plain": "最大已實現拖累是 {ticker};拿掉它後盈虧比會是 {after}。" + "rule_grounding": { + "averaging_down": "本期實況:你在虧損部位往下加碼 {count} 次,包括 {tickers}。", + "diversification": "本期實況:前幾大部位 {tickers} 合計佔 {pct}。", + "holding_period": "本期實況:{tickers} 同一檔既有短進短出、也有長抱。", + "position_sizing": "本期實況:最大單一部位 {ticker} 佔 {pct}。" }, - "cash_lines": { - "anchored_with_weight": "有餘額錨點的帳戶現金為 {cash},佔帳戶 {weight}。", - "anchored": "有餘額錨點的帳戶現金為 {cash}。", - "by_currency": "有餘額錨點的帳戶現金(原幣):{amounts}。", - "amount_joiner": "、" + "rule_targets": { + "count_unit": " 次", + "line": "本期會被它攔下的:{items}", + "more_suffix": "、及其他 {n} 檔" }, - "public_mirror": { - "structural_with_severity": "這次開場組合檢查把「{dim}」列為主要結構風險,風險壓力為{severity}。", - "structural": "這次開場組合檢查把「{dim}」列為主要結構風險。", - "structural_dim_default": "組合結構", - "snapshot_baseline": "這次開場組合檢查只建立結構基線;交易歷史行為維度維持未評分。", - "no_holes": "這次可用歷史不足以排序出主要行為模式。", - "behavioral": "這次復盤在「{dim}」看見{severity}程度的行為壓力。", - "behavioral_dim_default": "主要行為維度", - "structure": "配置型 ETF 與單一標的風險分開計算;產業、主題與槓桿 ETF 仍保留集中風險。" + "rule_tradeoff": { + "sizing_vs_hypothesis": "上限管的是單筆爆掉;押對賽道還沒驗證,更不該靠加大單注押它。", + "sizing_vs_proven_edge": "上限管的是單筆爆掉,不跟「選股是真 edge」衝突。" }, - "tag_values": { - "price_note": "(現 {px}/均 {avg_cost})" + "rules": { + "averaging_down": "往下加碼前寫出一個進場時不知道的新證據;寫不出,不加。", + "diversification": "新增部位前先看是否仍是同一個驅動因子;同一注已過重,不新增。", + "exit_discipline": "出場前先寫下交易論述已完成或失效的事實;只因害怕回吐,不賣。", + "holding_period": "進場時先標短線、波段或長線;出場只用同一時間框架的理由。", + "position_sizing": "單筆部位上限定死 {cap};超過就減,不新增。" + }, + "sections": { + "etf": "ETF 與組合結構", + "hole": "最大的行為漏洞", + "performance": "相對績效", + "rule": "下次只改這一件", + "strength": "你做對的一件事" }, "sectors": { "bonds": "債券", - "crypto": "加密", - "regional_etf": "區域ETF", - "semiconductors": "半導體", - "commodities": "商品", "broad_market_etf": "大盤ETF", - "unclassified": "未分類", + "commodities": "商品", "consumer": "消費", + "crypto": "加密", + "datacenter_power": "資料中心電力", "drones_defense": "無人機國防", + "ev_ai": "電動車AI", + "fintech": "金融科技", "rare_earth_materials": "稀土材料", - "datacenter_power": "資料中心電力", + "regional_etf": "區域ETF", + "semiconductors": "半導體", "software_cloud": "軟體雲", - "fintech": "金融科技", "telecom": "電信", - "ev_ai": "電動車AI" + "unclassified": "未分類" }, - "hole_lines": { - "joiner": ";", - "ticker_joiner": ", ", - "exit_forward": "{n_rt} 筆決策賣出({n_scored} 有 fwd、{n_trunc} 截斷)中 {early_rate} 在 {n_fwd} 天後更高、平均續漲 {avg_forgone}{winner_early}", - "exit_winner_early": ";賣掉賺錢的有 {winner_early} 續漲(賣太早)", - "exit_holding": "賺錢抱 {hold_win} 天 / 賠錢抱 {hold_lose} 天(處置缺口 {disp_gap})", - "position_sizing": "你最大一筆 {max_ticker} 佔 {max_pct},其餘平均 {avg_pct}", - "diversification": "你持有 {n} 檔看似分散,但 AI capex 暴險 {ai_pct}、最大板塊「{max_sector}」{max_sector_pct}、top3 {top3}——同一個驅動因子", - "holding_no_data": "暫無已實現 round-trip,持有時間統計待生成(只看買進尚未賣出的不納入)", - "holding_same_day": "所有已完成交易皆為當日進出,沒有隔夜持有", - "holding_base": "你持有時間 {min}~{max} 天、中位 {median_hold} 天", - "holding_inconsistent": "{base};其中 {n_incon}/{n_multi} 檔同一檔又當沖又長抱({tickers})——同檔沒有一致框架", - "holding_consistent": "{base}(中位 {median_hold} 天 = 你的主框架;同檔框架大致一致)", - "averaging_down": "你有 {count} 次在虧損倉往下加碼({tickers}),其中 {breach} 次加碼當下佔成本 >25%" - }, - "add_descriptions": { - "planned_tranche": "進場前已定好節奏,價格下跌不是新增理由。", - "new_evidence": "必須補 claim 與 source,之後能回頭驗證。", - "valuation_change": "判斷沒變,但價格讓賠率或安全邊際改變。", - "price_only": "沒有新事實,主要是想攤低成本或等回本。", - "skip": "先不定性,卡上只標未確認。" - }, - "generic_choices": { - "deliberate_plan": "事先規劃", - "emotional_reaction": "情緒反應", - "external_constraint": "外部限制", - "skip": "先跳過" - }, - "generic_descriptions": { - "deliberate_plan": "行動遵循交易前就存在的規則。", - "emotional_reaction": "恐懼、後悔或急迫感主導了行動。", - "external_constraint": "資金、稅務或其他限制主導了行動。", - "skip": "這次先不替動機下定論。" - }, - "initial_thesis_descriptions": { - "planned_entry": "進場前就有明確的論點。", - "momentum_follow": "追了價格動能或 FOMO。", - "external_call": "KOL、朋友或研究報告推薦的。", - "no_clear_thesis": "在沒有明確論點下持有。", - "skip": "這次先不替進場動機下定論。" - }, - "exit_descriptions": { - "price_target": "原先設定的目標或減碼條件已經完成。", - "thesis_broken": "原本判斷失效,或信心因新事實而下降。", - "swap": "資金改放到另一個標的或用途。", - "anxiety": "主要是怕回吐,所以先鎖住全部或部分成果。", - "other": "以上都不符合,用自己的話留下一句。", - "skip": "保存為已略過,之後不再追問這筆的賣出理由。" - }, - "due_descriptions": { - "still_valid": "理由仍成立,賣早也是紀律。", - "modified": "理由部分成立,需要修正。", - "falsified": "真的判斷錯誤,記進教訓。", - "skip": "不算已回答,下次復盤同一關會再出現。" + "snapshot": { + "holes": { + "clean_structure": "這次持倉快照沒有觸發集中度或分散度的結構風險。", + "leading_risk": "現有快照的主要結構風險是「{label}」。", + "no_weights": "這次開場檢查只建立結構基線;無法取得的權重不會被當成低風險。" + }, + "overview": { + "missing_avg_cost": "以下持倉缺少平均成本:{tickers}。", + "missing_fx": "以下幣別缺少可靠匯率:{currencies}。", + "opening": "這是針對{subject}的開場組合檢查。", + "opening_as_of": "這是針對{subject}的開場組合檢查,快照截至 {as_of}。", + "subject_generic": "使用者提供的持倉", + "subject_with_count": "使用者提供的 {positions} 個持倉", + "valuation_cost": "結構權重採使用者提供的成本口徑。", + "valuation_market_value": "結構權重採使用者提供的市值口徑。", + "valuation_unavailable": "目前沒有可靠估值口徑,因此不評分依賴權重的組合結構。" + }, + "strength": { + "baseline": "已用使用者提供的持倉建立結構基線;依賴權重的優勢暫不評分。", + "weighted": "已用你提供的持倉建立組合結構基線,並據此評分權重;缺少的輸入維持明示,不用推測補齊。" + } }, - "rule_breach_descriptions": { - "keep_tracking": "規矩合理,但這次沒有守住;照實記錄並繼續追。", - "revise_rule": "規矩本身不合理;在 note 簡述為何要改,收尾時用唯一 commitment 寫替代規矩。", - "exception": "這次有正當例外;在 note 留下理由,事件仍保留在帳上。" + "snapshot_hole": "主要結構風險", + "snapshot_numbers": "第一次持倉結構", + "snapshot_strength": "這份快照建立了什麼", + "split_lines": { + "line": "{subject}贏大盤的 {excess} 個百分點拆為:市場/賽道配置 {allocation} 個百分點、標的選擇 {selection} 個百分點。", + "subject_default": "", + "subject_market": "{market} " }, - "asked_because": { - "pnl_impact": "它是你本週影響損益最大的部位", - "position_cost": "它是你本週成本最大的部位", - "exit_notional": "它是你近期金額最大的出場之一" + "stress_test": { + "labels": { + "ai_thematic": "AI 概念股(跨板塊)", + "sector": "「{sector}」板塊", + "single_ticker": "單檔 {ticker}" + }, + "line": "你 {label} 暴險約 {exposure}(佔 {pct});回檔 30% → 帳面 −{drop30}、回檔 50% → −{drop50},撐得住嗎?" }, - "breach_evidence": { - "none": "帳上有一筆事件", - "more": ";另有 {extra} 筆", - "joiner": "; " + "tag_values": { + "price_note": "(現 {px}/均 {avg_cost})" }, - "candidate_comparison": { - "line": "本期「{top}」的訊號比「{others}」更強——這只反映本期哪個模式更明顯,不代表哪條規矩更適合你。", - "joiner": "、" + "title": "復盤卡", + "trends": { + "better": "改善", + "flat": "持平", + "worse": "惡化" } } diff --git a/skills/fomo-kernel/engine/coach.py b/skills/fomo-kernel/engine/coach.py index f5f72e72..3517e6e8 100644 --- a/skills/fomo-kernel/engine/coach.py +++ b/skills/fomo-kernel/engine/coach.py @@ -415,6 +415,7 @@ def cmd_save_card(args): ("conditions.jsonl", "jsonl", "引擎算不出來、但你仍然承諾要盯的條件(#412;含你自己的原話與當時的基準值)"), ("condition_checks.jsonl", "jsonl", "每條 conditions.jsonl 條件在每次復盤週期的查核紀錄(#412 第二刀;查了沒/觀察到什麼/你怎麼回答,連沒查的週期也算一列,不覆寫舊列)"), ("verdicts.jsonl", "jsonl", "引擎判定的「說到 vs 做到」落差記錄(#446;目前只有 horizon 矛盾,可重算驗證,不進規矩對帳)"), + ("position_rationales.jsonl", "jsonl", "你為什麼「現在還繼續持有」某個部位的原話(#403;一個部位一條鏈,改口是新增一列而不是改寫舊列,說「沒變」也算一列)"), ("problems.jsonl", "jsonl", "問題事件記錄(#137)"), ("ledger.jsonl", "jsonl", "交易/持倉快照帳本"), ("trade_evaluations.jsonl", "jsonl", "買進/賣出前的假設性評估記錄(review.py consider):凍結當時算出的 before/after/rule collision,--resolve 才追加你後續的決定,不覆寫舊列"), diff --git a/skills/fomo-kernel/engine/position_rationale.py b/skills/fomo-kernel/engine/position_rationale.py new file mode 100644 index 00000000..7332b528 --- /dev/null +++ b/skills/fomo-kernel/engine/position_rationale.py @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +position_rationale.py — why the user says they still hold one position (#403) + +A canonical append-only stream, ``/position_rationales.jsonl``: one chain +per active position cycle, recording the user's own words for why they continue +to hold it. Nothing here rewrites a row. A changed reason is a new event whose +predecessor is the one it follows, so the record keeps what was said and when it +stopped being current, rather than the current answer alone. + +This module owns the writer, the fold and one bounded reader (#450). It has no +CLI, reads no review session and never asks a question; the direct entry point, +the review collector and the light-capture migration are separate slices, and +until one of them lands a row in this file is not yet memory — it is a writer +without a reader, which is #429's failure class and is exactly why this module +ships with the bounded query rather than after it. + +The event contract here is the one the owner accepted on 2026-07-31 (#403, +"Maintainer disposition"), which superseded the issue body on two details. Both +are implemented below; neither is this module's invention. One addition is: +idempotency by head comparison rather than id collision, which the accepted +append semantics do not name and which the chain makes necessary. + +1. **The content address carries the predecessor.** Frozen, the address is the + payload minus ``recorded_at``, so a user who says A, then B, then A, then B — + one sitting, same day, same book — has their fourth statement hash identically + to their second and silently dropped as a retry, while the writer returns + success. That single case is the whole justification; an immediate repeat is + a no-op either way, and same-day confirmations collapse under head comparison + either way. + +2. **Idempotency is head comparison, not id collision.** The pointer alone + breaks the retry it was meant to preserve: a write that lands and then dies + before its receipt re-reads a head that is now its own row, so the retry + hashes differently and stores the same words twice. Comparing against the + head is what makes a retry a no-op, and it is what + ``review._append_evaluation_row`` already does for the same reason. + +3. **The stored discriminator is the user's act, not a derived label.** + ``initial`` versus ``changed`` is position in the chain, and the writer must + not freeze it: ``thesis.build_snapshot_cycle_relinks`` re-parents a subject + retroactively by design — Front A of this same issue — so an event honestly + written ``initial`` can end up behind three earlier ones on the merged + subject, with the row and the fold then disagreeing and nothing detecting it. + ``act`` records what the user did (said something / confirmed nothing + changed); ``supersedes is None`` records that nothing preceded it, which is + the same fact carried where a later discovery cannot falsify it. + +1 and 3 must travel together. Dropping the derived label while the address still +has no predecessor makes the collision *worse* — the label is currently acting +as an accidental discriminator, so A→B→A would collide at the third event +instead of the fourth. + +Three refusals rather than resolutions, all from the same accepted disposition: +a subject with two children of one predecessor, an ``expected_predecessor`` that +is no longer the head, and a review session returning with different words under +an id it already used. Each could be "resolved" by picking a winner; each winner +would be one the user never nominated. + +``mapping_status`` is not stored here, and that is settled rather than open +(owner disposition on PR #661). The row carries only ``condition_ref``: +``conditions.py`` owns the condition's current tier and failure reason, and the +integration receipt owns the user-visible ``recorded`` versus ``monitored`` +distinction. One reader, one fact. Duplicating monitorability here would also +rot by construction — a condition that is blind today can become watchable next +quarter through a new slot revision, while this row can never be updated. +""" +import datetime as dt +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import session # noqa: E402 +import thesis # noqa: E402 + + +class PositionRationaleError(ValueError): + """Refusal from this module. Never raised for a corrupt line on read.""" + + +SCHEMA_V = 1 +# What the user did. Deliberately not `initial | changed | no_change`: the first +# two are the chain's shape rather than the user's act, and reusing those tokens +# in stored data is the confusion this split exists to end. +ACTS = ("statement", "confirmation") +CAPTURE_SOURCES = ("direct", "review", "light_capture") +VOICE = "user_verbatim" +SUBJECT_KEYS = ("cycle_id", "ticker", "market", "currency") + +# The bounded reader's shape (#450): earliest, latest, and up to six more of the +# most recent remaining. Eight total, the same bound `review.CONDITION_LOOKUP_CAP` +# and `review.EVALUATION_RECONCILE_CAP` carry, and for the same reason — a plan +# surface re-sent as agent context on every later turn must not grow without one. +QUERY_CAP = 8 +RECENT_CAP = QUERY_CAP - 2 + + +def _rationale_path(root): + return os.path.join(root, "position_rationales.jsonl") + + +def _iso_date(value, label): + try: + return dt.date.fromisoformat(str(value)).isoformat() + except (TypeError, ValueError) as exc: + raise PositionRationaleError(f"{label} must be an ISO date (YYYY-MM-DD)") from exc + + +def _subject(raw): + """Validate the engine-resolved subject. The caller never supplies a guess. + + Every key is required: a rationale joined on ticker alone would cross an + unproven sale and rebuy boundary, which is precisely what #403 forbids and + what the market/currency pair exists to make impossible. + """ + if not isinstance(raw, dict): + raise PositionRationaleError("subject must be an object") + missing = [key for key in SUBJECT_KEYS if not raw.get(key)] + if missing: + raise PositionRationaleError("subject is missing: " + ", ".join(missing)) + extra = sorted(set(raw) - set(SUBJECT_KEYS)) + if extra: + raise PositionRationaleError("subject has unknown fields: " + ", ".join(extra)) + return {key: str(raw[key]) for key in SUBJECT_KEYS} + + +def _readable(row): + """Is this row safe for every reader below to dereference? + + Deliberately strict about the fields the fold and the bounded query read + without guarding — a row admitted here and missing one of them would crash + the reader while `unreadable` still said the file was clean, which inverts + the degrade-then-say-by-how-much contract instead of merely weakening it. + `voice` is checked for the same reason it is a const: a row claiming any + other voice must never be handed back as the user's own words. + """ + if not isinstance(row, dict): + return False + subject = row.get("subject") + if not isinstance(subject, dict) or any( + not isinstance(subject.get(key), str) or not subject[key] for key in SUBJECT_KEYS): + return False + if not isinstance(row.get("supersedes"), str) and row.get("supersedes") is not None: + # An unhashable or non-string pointer would raise out of the chain walk + # and take reads *and* writes for this subject with it, permanently. + return False + return (isinstance(row.get("event_id"), str) and row["event_id"] + and row.get("act") in ACTS + and isinstance(row.get("stated_at"), str) and row["stated_at"] + and row.get("voice") == VOICE + and row.get("capture_source") in CAPTURE_SOURCES + # A schema-v1 row is validated as complete, not merely as walkable. + # A row missing the book that resolved it, or when the kernel heard + # it, is not a lesser record of the same fact — it is a row this + # contract never produced, and admitting it lets a reader answer + # questions the row cannot support. + and row.get("schema_version") == SCHEMA_V + and isinstance(row.get("recorded_at"), str) and row["recorded_at"] + and isinstance(row.get("state_version"), str) and row["state_version"]) + + +def load(path): + """Return ``(rows, unreadable)`` — every stored event in file order, and how + many lines could not be read. + + One corrupt line must not cost the user the rest of what they said, but a + silent skip would make corruption look like a statement they never made, and + it would make the bounded reader's ``total`` a lie. ``conditions.load_slots`` + sets the precedent: degrade, then say by how much. + """ + rows, unreadable = [], 0 + if not path or not os.path.exists(path): + return rows, unreadable + # errors="replace" so one truncated multi-byte character costs that line + # rather than every statement in the file: a crash mid-write of a non-ASCII + # statement would otherwise raise on every later read *and* every later + # append, which is the opposite of what this function promises. + with open(path, encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except ValueError: + unreadable += 1 + continue + if _readable(row): + rows.append(row) + else: + unreadable += 1 + return rows, unreadable + + +def _subject_ids(cycle_id, aliases): + """The one subject, under every id the engine has proven to be it. + + Identity is ``thesis.py``'s domain, not this module's: the caller passes the + engine-proven relinks (``thesis.build_snapshot_cycle_relinks``) rather than + this reader guessing at continuity. #563's later-history upgrade moves a + cycle's start, and a reader that matched the current id alone would return + nothing for statements the user still owns — remembered, then silently gone, + which is the failure this stream exists to prevent. + """ + return {str(cycle_id)} | {str(alias) for alias in (aliases or ()) if alias} + + +def chain_for(rows, cycle_id, aliases=()): + """Every event on one subject, oldest first, in a deterministic topological + order that preserves every predecessor edge. + + A global sort on chain depth is wrong once a proven relink merges two + histories onto one subject: depth would beat date, so every depth-1 and + depth-2 event of an older chain sorts *after* a newer independent root, and + the user's most recent statement stops being the latest one. Kahn's + algorithm instead: a row is emitted only after the row it supersedes, and + among the rows currently available the earliest `stated_at` goes first, with + file order breaking a same-day tie. So `A1→A2→A3` plus a newer root `B1` + reads A1, A2, A3, B1 — every edge intact and B1 still latest. + + A row whose predecessor is absent from the file — a dangling pointer left by + a truncated copy — is a root rather than a dropped statement. A cycle, which + only a hand-edited file can contain, drains at the end rather than vanishing. + """ + wanted = _subject_ids(cycle_id, aliases) + own = [row for row in rows if (row.get("subject") or {}).get("cycle_id") in wanted] + by_id, order = {}, {} + for index, row in enumerate(own): + by_id.setdefault(row["event_id"], row) + order.setdefault(row["event_id"], index) + + children, indegree = {}, {} + for event_id, row in by_id.items(): + parent = row.get("supersedes") + linked = bool(parent) and parent in by_id and parent != event_id + indegree[event_id] = 1 if linked else 0 + if linked: + children.setdefault(parent, []).append(event_id) + + def _key(event_id): + return (by_id[event_id].get("stated_at") or "", order[event_id], event_id) + + available = sorted((e for e, d in indegree.items() if not d), key=_key) + emitted, seen = [], set() + while available: + event_id = available.pop(0) + if event_id in seen: + continue + seen.add(event_id) + emitted.append(by_id[event_id]) + for child in children.get(event_id, ()): + indegree[child] -= 1 + if indegree[child] <= 0 and child not in seen: + available.append(child) + available.sort(key=_key) + + # Whatever a cycle stranded. Kept, never dropped: losing a statement to a + # hand-edited file is the loss this stream exists to stop. + stranded = sorted((e for e in by_id if e not in seen), key=_key) + return emitted + [by_id[event_id] for event_id in stranded] + + +def duplicate_ids_in(rows, cycle_id, aliases=()): + """Event ids carried by two rows with different content. + + Content addressing makes this unreachable in normal operation, so reaching + it means the file was edited outside the engine. Selecting between them by + file order would pick a version of the user's words that nothing nominated. + An identical repeat of one row is not a conflict — that is a write that + landed twice, and both copies say the same thing. + """ + wanted = _subject_ids(cycle_id, aliases) + seen, conflicting = {}, set() + for row in rows: + if (row.get("subject") or {}).get("cycle_id") not in wanted: + continue + event_id = row["event_id"] + canonical = session.canonical(row) + if event_id in seen and seen[event_id] != canonical: + conflicting.add(event_id) + seen.setdefault(event_id, canonical) + return sorted(conflicting) + + +def forks_in(rows, cycle_id, aliases=()): + """Predecessors with more than one child — a corrupt subject, reported not healed. + + Two events superseding the same predecessor cannot both be the current + reason, and choosing between them by file order or timestamp would pick a + winner the user never nominated. A reader must still return what it can, so + `chain_for` linearizes rather than crashing; this is how it says the order it + produced is not the record's own. `append_locked` refuses outright, because + a new event superseding an ambiguous head would bury one branch for good. + """ + wanted = _subject_ids(cycle_id, aliases) + children = {} + for row in rows: + if (row.get("subject") or {}).get("cycle_id") not in wanted: + continue + parent = row.get("supersedes") + if parent: + children.setdefault(parent, []).append(row["event_id"]) + return sorted(parent for parent, kids in children.items() if len(kids) > 1) + + +def classify(rows, cycle_id, event_id=None, aliases=()): + """Derive `initial | changed | no_change` for one event — the frozen + vocabulary, computed by the one reader rather than stored by the writer. + + Lives here so every consumer reads the same derivation. An integration + surface that rolled its own would be the second reader of one fact, which is + the defect not storing the label was meant to end. + """ + chain = chain_for(rows, cycle_id, aliases) + if not chain: + return None + row = next((r for r in chain if r["event_id"] == event_id), chain[-1]) \ + if event_id else chain[-1] + if row.get("act") == "confirmation": + return "no_change" + # Position in the merged chain, never the pointer alone. On a subject a + # proven relink merged, the later root is not the user's first statement + # about that position — and saying it is would tell the next review to open + # as though they had never explained it. + return "initial" if chain[0]["event_id"] == row["event_id"] else "changed" + + +def head_of(rows, cycle_id, aliases=()): + """The subject's latest event, or None. What a new event supersedes.""" + chain = chain_for(rows, cycle_id, aliases) + return chain[-1] if chain else None + + +def effective_statement(rows, cycle_id, aliases=()): + """The wording currently in force: the latest event that is a statement. + + Derived by walking back through confirmations rather than stored, and never + copied onto a confirmation row. A user who confirms nothing changed has not + restated their reason, and recording it as though they had would put words + in their mouth on a date they did not say them. + """ + for row in reversed(chain_for(rows, cycle_id, aliases)): + if row.get("act") == "statement": + return row + return None + + +def build_event(*, subject, act, capture_source, state_version, stated_at=None, + supersedes=None, user_statement=None, origin_id=None, + condition_ref=None, recorded_at=None): + """Build one validated, content-addressed event. Writes nothing. + + ``recorded_at`` is stamped after the identity is computed and is never part + of it, so a replay of the same act reproduces the same id. Optional fields + are omitted from the identity rather than sent as null: a key that appears + only sometimes moves the digest of every row that lacks it, which is the + defect ``review._evaluation_id``'s docstring records at length. + """ + if act not in ACTS: + raise PositionRationaleError(f"act must be one of: {', '.join(ACTS)}") + if capture_source not in CAPTURE_SOURCES: + raise PositionRationaleError( + f"capture_source must be one of: {', '.join(CAPTURE_SOURCES)}") + if not state_version: + raise PositionRationaleError( + "state_version is required: a rationale states which book resolved its subject") + # The act decides whether words are owed, and the validator enforces it in + # both directions. A confirmation carrying a statement would be a second + # copy of wording the user did not restate; a statement without one would be + # an empty row claiming they said something. + if act == "statement": + if not isinstance(user_statement, str) or not user_statement.strip(): + raise PositionRationaleError("a statement requires the user's own words") + elif user_statement is not None: + raise PositionRationaleError( + "a confirmation carries no statement: it says the prior wording still holds, " + "and copying that wording forward would restate it on a day it was not said") + if act == "confirmation" and not supersedes: + raise PositionRationaleError( + "a confirmation must name the event it confirms; there is nothing to confirm first") + + identity = { + "schema_version": SCHEMA_V, + "subject": _subject(subject), + "act": act, + # Frozen default: the invocation's local calendar date. Sub-day precision + # is deliberately not what separates two same-day revisions — the + # predecessor chain is. A user referring to an earlier day supplies that + # date; nothing here invents one on their behalf. + "stated_at": _iso_date(stated_at or dt.date.today().isoformat(), "stated_at"), + # Always present, null for a first event. Unlike the optional keys below + # this one is constant across every row, so a null cannot shift another + # row's digest — and it is the fact `initial` used to claim. + "supersedes": supersedes or None, + "voice": VOICE, + "capture_source": capture_source, + "state_version": str(state_version), + } + if user_statement is not None: + # Byte-for-byte. Not stripped, not normalized, not tidied into product + # phrasing — the whole point of the stream is that these are their words. + identity["user_statement"] = user_statement + if supersedes is not None and not isinstance(supersedes, str): + raise PositionRationaleError("supersedes must be an event id string or None") + for key, value in (("origin_id", origin_id), ("condition_ref", condition_ref)): + if value is None: + continue + if not isinstance(value, str) or not value.strip(): + # Omitting an empty string would make it indistinguishable from + # absent in the address, so two different sessions could hash + # identically and the second act be lost as a retry. + raise PositionRationaleError(f"{key} must be a non-empty string when supplied") + identity[key] = value + + row = dict(identity) + row["event_id"] = thesis.stable_event_id("position-rationale", identity) + row["recorded_at"] = (recorded_at or dt.datetime.now(dt.timezone.utc) + .replace(microsecond=0).isoformat()) + return row + + +def append(root, **kwargs): + """Append one rationale under the root write lock. See ``append_locked``. + + A caller already inside ``session.projection_transaction`` must call + ``append_locked`` instead: the lock is a POSIX ``flock`` taken per file + descriptor, so re-entering it from the same process deadlocks rather than + nesting. That is why the two are separate functions instead of a flag. + """ + with session.projection_transaction(root) as locked_root: + return append_locked(locked_root, **kwargs) + + +def append_locked(root, *, subject, act, capture_source, state_version, + stated_at=None, user_statement=None, origin_id=None, + condition_ref=None, recorded_at=None, expected_predecessor=None, + aliases=()): + """Resolve the subject's head, then append unless this act is already recorded. + + Two idempotency rules, and they answer different questions. + + **Head comparison** answers "did this exact act just happen?" — a retry + whose write landed before the receipt was lost finds its own row as the head + and returns it rather than storing the same words twice. This is the reason + the id alone cannot carry idempotency once the predecessor is in the address: + the retry reads a different head, so it hashes differently. + + **Session comparison** answers "has this review already said this?" — a + re-finalized session must not resurrect its statement over a newer direct + one the user has since recorded. Keyed on the producing session and subject, + mirroring ``session._append_session_rows``, and checked before the head so a + replay is a no-op regardless of what has happened since. + + An immediate repeat of the head's exact act is a no-op, deliberately and by + the accepted contract — "identical payload against the same predecessor is + an idempotent no-op". What the chain buys is the *non-immediate* repeat: the + fourth event of A→B→A→B is a real re-statement with its own id, where the + address without a predecessor would have swallowed it. Saying the user can + restate identical words back-to-back would be a promise this code does not + keep, and the contract does not ask it to. + + Two further refusals the accepted disposition requires, both fail-closed + rather than resolved by picking a winner: an already-forked subject, and an + `expected_predecessor` that is no longer the head. + """ + path = _rationale_path(root) + rows, unreadable = load(path) + cycle_id = _subject(subject)["cycle_id"] + + if unreadable: + # A reader may degrade and say by how much; a writer may not. A line this + # loader could not read may be the subject's real head, so appending + # against the last *readable* one can root a second branch or make an + # older reason current — while returning success. Repair the stream + # first; the user's existing words are still there to repair from. + raise PositionRationaleError( + f"{path} has {unreadable} unreadable line(s); appending against a head " + "this writer cannot see could bury a statement you already made. " + "Repair the file before recording anything new") + + duplicates = duplicate_ids_in(rows, cycle_id, aliases) + if duplicates: + raise PositionRationaleError( + f"{cycle_id} carries conflicting content under {', '.join(duplicates)}; " + "the stream was edited outside the engine and file order must not " + "decide which version of your words is real") + + forks = forks_in(rows, cycle_id, aliases) + if forks: + raise PositionRationaleError( + f"{cycle_id} has two events superseding {', '.join(forks)}; the head is " + "ambiguous and appending would bury one branch. Repair the stream rather " + "than letting file order choose which reason the user meant") + + head = head_of(rows, cycle_id, aliases) + head_id = (head or {}).get("event_id") + row = build_event(subject=subject, act=act, stated_at=stated_at, + capture_source=capture_source, state_version=state_version, + supersedes=head_id, user_statement=user_statement, + origin_id=origin_id, condition_ref=condition_ref, + recorded_at=recorded_at) + + if capture_source == "review" and origin_id: + for prior in chain_for(rows, cycle_id, aliases): + if not (prior.get("capture_source") == "review" + and prior.get("origin_id") == str(origin_id)): + continue + if _same_answer(prior, row): + return {"path": path, "appended": 0, "status": "no-op", + "event_id": prior["event_id"]} + # A replay is a no-op; a session that comes back saying something + # *different* under the same id is not a replay, and silently + # keeping the older words is the loss this stream exists to stop. + # `session._append_session_rows` refuses the same shape. + raise PositionRationaleError( + f"session {origin_id} already recorded a different rationale for {cycle_id} " + f"({prior['event_id']}); a re-finalize may not silently replace the user's " + "words. Record the correction as a new statement instead") + + # After the session replay check, deliberately. A re-finalize reads a head + # that its own first run moved, so checking the caller's expected predecessor + # first would turn a documented-safe retry into a refusal — and "retrying the + # same session with identical content is a no-op" is a never-loosen + # invariant. Ordered this way, the stale-head guard still covers every case + # it exists for: a *different* writer landing between the caller's read and + # this append. + if expected_predecessor is not None and expected_predecessor != head_id: + raise PositionRationaleError( + f"expected predecessor {expected_predecessor or '(none)'} but the head is " + f"{head_id or '(none)'}; something was recorded in between. Re-read and retry " + "rather than forking the subject") + if head is not None and _same_act(head, row): + return {"path": path, "appended": 0, "status": "no-op", + "event_id": head["event_id"]} + + existing = next((r for r in rows if r.get("event_id") == row["event_id"]), None) + if existing is not None: + # Content-addressed ids are unique by construction once the predecessor + # is in the address, so reaching here means the file disagrees with the + # address that named it. Fail closed rather than pick a winner. + raise PositionRationaleError( + f"{row['event_id']} already exists with different content; " + "the stream was edited outside the engine") + + os.makedirs(root, exist_ok=True) + prefix = "" + if os.path.exists(path) and os.path.getsize(path) > 0: + with open(path, "rb") as handle: + handle.seek(-1, os.SEEK_END) + if handle.read(1) != b"\n": + # A prior crash left no trailing newline; appending onto that + # line would corrupt two rows instead of writing one. + prefix = "\n" + with open(path, "a", encoding="utf-8") as handle: + handle.write(prefix + json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") + # #403 requires the root lock *plus* flush/fsync: a receipt that names an + # event id is a promise the row is on disk, and returning it before the + # bytes are durable makes a power loss look like a statement the user + # never made. `session._fsync_file` sets the discipline for canonical + # writes; the .jsonl appenders that skip it are not returning an id. + handle.flush() + os.fsync(handle.fileno()) + return {"path": path, "appended": 1, "status": "appended", "event_id": row["event_id"]} + + +def _same_answer(prior, candidate): + """Did this session already record this answer? + + Compared on what the user said — the act and their words — and deliberately + not on `state_version` or `stated_at`. Both are re-derived at write time: + the book moves when the user hands over a newer file, and the date rolls + over at midnight. Including them made a byte-identical finalize retry raise + "already recorded a different rationale", which is false, and whose stated + remedy would append a statement the user never made. "Retrying the same + session with identical content is a no-op" is a never-loosen invariant. + """ + return all(prior.get(key) == candidate.get(key) + for key in ("act", "user_statement", "condition_ref")) + + +def _same_act(head, candidate): + """Is the candidate the same act the head already records? + + Compared on what the user did, never on the whole row: ``recorded_at`` + differs on every retry by construction, and ``supersedes`` differs precisely + in the crashed-retry case this exists to absorb. + """ + keys = ("act", "stated_at", "user_statement", "capture_source", "origin_id", + "condition_ref", "state_version") + return all(head.get(key) == candidate.get(key) for key in keys) + + +def query(root, cycle_id, *, cap=QUERY_CAP, aliases=()): + """The bounded position-rationale reader (#450). + + Returns the earliest event, the latest, and up to six of the most recent + remaining — with ``total``, ``shown`` and ``beyond_cap`` so no reader can + mistake the list for the whole record. The span, the totals and the + effective statement are computed from the *untruncated* chain: the cap + exists to keep a plan surface readable, never to shrink the durable record. + + ``unreadable`` is reported rather than swallowed. A corrupt line means the + count is a floor, and a reader told a floor is a lie is worse off than one + told nothing. + """ + path = _rationale_path(root) + rows, unreadable = load(path) + chain = chain_for(rows, cycle_id, aliases) + total = len(chain) + if not chain: + return {"cycle_id": cycle_id, "items": [], "effective": None, "latest": None, + "change": None, "span": None, "total_count": 0, "shown_count": 0, "beyond_cap": 0, + "unreadable": unreadable, "forked": [], "conflicting_ids": []} + + earliest, latest = chain[0], chain[-1] + if total == 1: + shown = [earliest] + elif total == 2: + shown = [earliest, latest] + else: + # Earliest and latest are always sent; the rest of the budget goes to the + # most recent remaining, because a reason's recent history is what a + # question is worth asking against. + room = max(0, cap - 2) + # `x[-0:]` is `x[0:]`, so a cap of 0/1/2 would silently return the whole + # chain while reporting `beyond_cap: 0` -- the bounded surface unbounded, + # asserting nothing was held back. + middle = chain[1:-1][-room:] if room else [] + shown = [earliest] + middle + [latest] + dates = sorted(row["stated_at"] for row in chain) + return { + "cycle_id": cycle_id, + "items": shown, + # Derived, never stored: the wording in force is the latest statement, + # which a run of confirmations does not change. + "effective": effective_statement(rows, cycle_id, aliases), + "latest": latest, + # The frozen `initial | changed | no_change` vocabulary, derived here so + # no consumer has to re-derive it and disagree. + "change": classify(rows, cycle_id, aliases=aliases), + # Non-empty means the subject is forked and this order is the reader's, + # not the record's. A caller must not present it as the user's history. + "forked": forks_in(rows, cycle_id, aliases), + # Conflicting content under one id: the file was edited outside the + # engine, and nothing here picks a winner. + "conflicting_ids": duplicate_ids_in(rows, cycle_id, aliases), + "span": {"first": dates[0], "last": dates[-1]}, + # #450 froze these two names; do not shorten them to match + # `_evaluation_reconciliation`'s local spelling. + "total_count": total, + "shown_count": len(shown), + "beyond_cap": total - len(shown), + "unreadable": unreadable, + } diff --git a/skills/fomo-kernel/engine/review.py b/skills/fomo-kernel/engine/review.py index 9ff18f5e..254fb4d4 100644 --- a/skills/fomo-kernel/engine/review.py +++ b/skills/fomo-kernel/engine/review.py @@ -38,6 +38,7 @@ import price_feed import problems import portfolio_basis +import position_rationale import question_surface import revisit import session @@ -2293,6 +2294,185 @@ def _thesis_cycle_index(positions, thesis_rows, cycle_relinks): return index +def _cycle_aliases(thesis_rows, cycle_relinks=()): + """``{cycle_id: [every prior id for it]}``, transitively closed. + + A relink row records one hop — ``cycle_provenance.from_cycle_id`` → ``cycle_id`` + — and #563's later-history upgrade can move the same position twice, so A→B→C + must resolve C's aliases to both B and A. ``_thesis_cycle_index`` walks the + same edges for a different answer (is this id live?) and is deliberately + single-hop; this is the per-cycle list a rationale reader needs to find the + statements the user made before their position's start date moved. + + Takes the same two ingredients as `_thesis_cycle_index` and concatenates them + the same way, so no third composition of history-plus-this-session exists. + """ + rows = [row for row in list(thesis_rows or []) + list(cycle_relinks or []) + if isinstance(row, dict)] + direct = {} + for row in rows: + provenance = row.get("cycle_provenance") + origin = provenance.get("from_cycle_id") if isinstance(provenance, dict) else None + target = row.get("cycle_id") + if origin and target and origin != target: + direct.setdefault(target, set()).add(origin) + + closed = {} + for target in direct: + seen, frontier = set(), list(direct[target]) + while frontier: + node = frontier.pop() + if node in seen or node == target: + continue + seen.add(node) + frontier.extend(direct.get(node, ())) + if seen: + closed[target] = sorted(seen) + return closed + + +def _alias_map(root, cycle_relinks=()): + """``{cycle_id: [every prior id]}`` for this root — the one composition. + + Exists as a named helper rather than three inline call sites because + `_thesis_event_history` returns a *pair* of row lists, and passing that pair + whole made `_cycle_aliases` filter both away as non-dicts and return `{}`. + Silent: the statement stayed on disk while the plan asserted the user had + never given a reason. One place to get it right, one place to test. + """ + history_rows, decision_rows = _thesis_event_history(root) + return _cycle_aliases(list(history_rows) + list(decision_rows), cycle_relinks) + + +def _resolve_rationale_subject(root, ticker): + """Resolve a ticker to the one active position cycle a rationale attaches to. + + #403's route rules, and they fail closed rather than guessing: the canonical + book is the only authority, an exited ticker is not a holding rationale, and + a symbol that does not match exactly is a miss rather than something to + case-fold into a position the user did not name. Returns the four-key subject + `position_rationale` requires plus the `state_version` of the book that + resolved it, so the stored row can name the book that said the position was + held. + """ + ledger_path = os.path.join(root, "ledger.jsonl") + try: + events, skipped_lines = ledger.load_ledger(ledger_path) + except ledger.LedgerIntegrityError as exc: + raise ReviewError(str(exc)) from exc + if not events: + raise ReviewError( + f"no recorded book in {ledger_path}; a holding rationale is about a position " + "this book says you hold, so run `prepare` or `refresh` first") + basis = portfolio_basis.query_current_book( + events, skipped_lines=skipped_lines, + reference_as_of=dt.date.today().isoformat(), splits=_effective_splits(root, None)) + if basis is None: + raise ReviewError( + f"no trustworthy canonical current book in {ledger_path}; the ledger may be " + "unreadable or its integrity checks failed, and a rationale must not be " + "attached to a position nobody could confirm you hold") + holdings = basis.current_book["holdings"] + holding = holdings.get(ticker) + if not isinstance(holding, dict) or not holding.get("cycle_id"): + held = ", ".join(sorted(holdings)) or "nothing" + raise ReviewError( + f"{ticker} is not an open position in the recorded book (currently held: {held}); " + "a holding rationale needs a position you hold. If this is a trade you are " + "considering rather than one you own, that is a different question") + subject = {"cycle_id": holding["cycle_id"], "ticker": ticker, + "market": holding.get("market") or "", "currency": holding.get("currency") or ""} + missing = [key for key in ("market", "currency") if not subject[key]] + if missing: + raise ReviewError( + f"the recorded book does not state {ticker}'s " + " or ".join(missing) + + "; a rationale joined without them could cross a same-ticker boundary, so " + "this fails closed rather than guessing") + return subject, basis.state_version + + +# The bounded rationale surface (#403/#450). Same bound and the same reason as +# CONDITION_LOOKUP_CAP: the plan is re-sent as agent context on every later turn, +# so a portfolio of forty positions must not grow it without limit. +RATIONALE_LOOKUP_CAP = 8 + + +def _rationale_state(root, positions, thesis_cycles, as_of, cycle_relinks=()): + """What the user has already said about why they hold each position. + + A supply-side fact surface, not a card section and not a decision: the engine + states what was said and how long ago, and whether that earns a question this + week is the agent's call against everything else competing for the budget + (#403's own ruling — importance is situational). + + Ordered oldest-statement-first, because staleness is what makes re-asking + worth a slot, and capped like `_condition_due` with a summary beside it so + the list can never be read as the whole record. Positions with nothing + recorded are counted but not listed: this surface exists so a prior statement + can be quoted back, and there is nothing to quote for them. + """ + aliases = _alias_map(root, cycle_relinks) + entries, without = [], 0 + for ticker, holding in sorted((positions or {}).items()): + cycle_id = holding.get("cycle_id") if isinstance(holding, dict) else None + if not cycle_id: + continue + view = position_rationale.query(root, cycle_id, aliases=aliases.get(cycle_id, [])) + effective = view.get("effective") + if not effective: + without += 1 + continue + entries.append({ + "ticker": ticker, + "cycle_id": cycle_id, + # The user's own words, carried verbatim so the question can quote + # rather than paraphrase. Never rewritten into product phrasing. + "statement": effective.get("user_statement"), + "stated_at": effective.get("stated_at"), + # Against today, not the review's `date_end`: `date_end` is where the + # trade history stops, and "how long since you last told me why" is a + # question about now. A file whose trades end two years ago would + # otherwise report every statement as negative days old. + "days_since": _days_between(effective.get("stated_at"), as_of), + "last_event_id": (view.get("latest") or {}).get("event_id"), + "statements_recorded": view.get("total_count"), + "change": view.get("change"), + # Non-empty means the subject is forked and the order is the + # reader's, not the record's — never present it as their history. + "forked": view.get("forked") or [], + }) + + # Whole-file, read once. `query` reports the same count for every subject, so + # summing it per position would multiply the damage by the book's size — in + # the one number that tells the user how damaged their record is. + _rows, unreadable = position_rationale.load(position_rationale._rationale_path(root)) + entries.sort(key=lambda row: (row["days_since"] is None, -(row["days_since"] or 0), + row["ticker"])) + shown = entries[:RATIONALE_LOOKUP_CAP] + summary = { + "positions_with_a_recorded_reason": len(entries), + "positions_without_one": without, + "shown": len(shown), + "beyond_cap": max(0, len(entries) - len(shown)), + "unreadable_events": unreadable, + # Computed over the full set, never the shown slice: the cap keeps the + # surface readable and must never shrink what it reports about. + # None when nothing has a usable date, never 0 — reporting "the oldest + # reason is 0 days old" would say the user just told us. + "oldest_days_since": max((row["days_since"] for row in entries + if row["days_since"] is not None), default=None), + } + return shown, summary + + +def _days_between(earlier, later): + """Whole days from one ISO date to another, or None if either is unusable.""" + try: + return (dt.date.fromisoformat(str(later)) - dt.date.fromisoformat(str(earlier))).days + except (TypeError, ValueError): + return None + + def _plan_thesis_cycles(plan): """The cycle index for a plan that already exists — the single way any finalize-side consumer obtains one. @@ -2975,10 +3155,73 @@ def _rejection(id_, kind, reason, cycle_id=None): return {"id": id_, "kind": kind, "cycle_id": cycle_id, "reason": reason} +# At most one per review (#403). This is the same slot every other kind competes +# for, not an extra one: a position whose recorded reason is going stale is worth +# a question only when it beats everything else this week, and the density policy +# already owns that trade-off. +RATIONALE_REFRESH_LIMIT = 1 +# Below this a statement is recent enough that re-asking learns nothing — the +# user would just read back what they wrote weeks ago. Deliberately generous: +# #403's value is a *series* over quarters, and a question that arrives too often +# trains the user to answer it without thinking, which is worse than not asking. +RATIONALE_REFRESH_MIN_DAYS = 45 + + +def _rationale_refresh_id(cycle_id, event_id): + return "rationale_" + thesis.stable_event_id("rationale-refresh", { + "cycle_id": str(cycle_id or ""), "event_id": str(event_id or "")})[-12:] + + +def _rationale_refresh_question(entry, language): + """One question quoting what the user already said, so the review confirms or + corrects rather than asking them to reconstruct it (#636's shape, #403's + subject). Engine-rendered: the stem is a dated quote of their own words, and + nothing here paraphrases them. + """ + copy = card_renderer.load_copy(language) + stem = (copy.get("rationale_refresh") or {}).get("stem") or "" + question = stem.format(ticker=entry.get("ticker") or "", + statement=entry.get("statement") or "", + stated_at=entry.get("stated_at") or "") + return { + "id": _rationale_refresh_id(entry.get("cycle_id"), entry.get("last_event_id")), + "kind": "rationale_refresh", "ticker": entry.get("ticker"), + "cycle_id": entry.get("cycle_id"), "required": True, "question": question, + "options": _options_from_copy(language, "rationale_refresh_choices", + ("same", "changed", "skip")), + "prior_rationale": { + "statement": entry.get("statement"), + "stated_at": entry.get("stated_at"), + "event_id": entry.get("last_event_id"), + "days_since": entry.get("days_since"), + "statements_recorded": entry.get("statements_recorded") or 0, + }, + # Staleness is the whole claim to a slot, so it is also the importance. + "_importance": min(1.0, (entry.get("days_since") or 0) / 365.0), + "_importance_basis": "days since the user last stated this reason", + "_tie": 6, + } + + +def _rationale_refresh_candidates(rationale_state, language): + """The refresh questions this review could ask, most stale first. + + A forked subject is skipped rather than asked about: the reader's order is + not the record's there, so the question could quote a statement the user did + not most recently make. Fail closed on the question, not on the review. + """ + eligible = [entry for entry in (rationale_state or []) + if entry.get("statement") and entry.get("last_event_id") + and not entry.get("forked") + and (entry.get("days_since") or 0) >= RATIONALE_REFRESH_MIN_DAYS] + return [_rationale_refresh_question(entry, language) for entry in eligible] + + def _question_queue(card, state, active, previous_state, language, recent_exits=None, thesis_states=None, due_revisits=None, problem_stats=None, rule_history=None, horizon_markers=None, route=None, missing_thesis_positions=None, tier=None, - condition_questions=None, evaluation_recall=None): + condition_questions=None, evaluation_recall=None, + rationale_state=None): """Return (queue, selection_report). The report states, plan-internally, how the route's density band was filled: the eligible/selected counts, why the queue fell short of the route minimum, and every candidate rejected with its @@ -3130,6 +3373,11 @@ def _question_queue(card, state, active, previous_state, language, recent_exits= # than behind the motive questions. Built by _build_plan (it needs the # store), budgeted there too — this only ranks what arrives. candidates.extend(condition_questions or []) + # #403: at most one, and only when the recorded reason is old enough that + # re-asking can learn something. It competes in the same band as everything + # else — this adds a candidate, never a guaranteed slot. + candidates.extend( + _rationale_refresh_candidates(rationale_state, language)[:RATIONALE_REFRESH_LIMIT]) breach_questions = _rule_breach_questions(problem_stats, rule_history, language) candidates.extend(breach_questions[:RULE_BREACH_LIMIT]) for row in breach_questions[RULE_BREACH_LIMIT:]: @@ -3910,6 +4158,14 @@ def _build_plan(card, state, engine_meta, root, paths, route, language, fingerpr condition_due, condition_summary, thesis_links, condition_retired = ( ([], None, {}, []) if route == "snapshot_review" else _condition_due(root, thesis_cycles, (previous or {}).get("date_end"))) + # #403/#450: what the user already told us about why they still hold each + # position. Snapshot reviews are excluded for the same reason they queue no + # questions. Attached unconditionally otherwise, empty included, so "nothing + # was recorded" is a claim the plan makes rather than a key that is missing. + rationale_state, rationale_summary = ( + ([], None) if route == "snapshot_review" + else _rationale_state(root, positions, thesis_cycles, + dt.date.today().isoformat(), cycle_relinks)) # #317/#429: reconcile any `consider` evaluation still open against what # the local ledger actually shows happened. `_ledger_trade_events` reads # only real, dated trade events — never `_rows_from_ledger`'s synthesized @@ -3944,7 +4200,8 @@ def _build_plan(card, state, engine_meta, root, paths, route, language, fingerpr problem_stats, rule_history, horizon_markers, route=route, missing_thesis_positions=missing, tier=review_tier["tier"], condition_questions=condition_questions, - evaluation_recall=_evaluation_recall(root)) + evaluation_recall=_evaluation_recall(root), + rationale_state=rationale_state) question_selection["rejected"].extend(condition_deferred) candidate_rules = _candidate_rules(card, state, language) plan = { @@ -3981,6 +4238,13 @@ def _build_plan(card, state, engine_meta, root, paths, route, language, fingerpr # verdict, never the prose (references/condition-slots.md). "condition_slots_due": condition_due, "condition_slots_summary": condition_summary, + # #403/#450: the reason the user gave for still + # holding each position, oldest first, with the + # summary stating what the cap held back. A fact + # surface — whether any of it earns a question this + # week is the agent's judgment, not the engine's. + "position_rationales": rationale_state, + "position_rationales_summary": rationale_summary, # #416 C2: the thesis conditions that stopped being # checked *this* period, because their position was # fully exited. An event, so the card says it once; @@ -5521,6 +5785,69 @@ def cmd_preview(args): "paths": paths, "next_action": "show the review-card preview (delivery contract: references/card-delivery.md); ask the user to choose one rule or skip; then finalize"}) +def _record_rationale_answers(plan, answers, amap): + """Write what the review learned about a holding reason into the canonical + stream, through the one append service (#403) rather than a bundle key. + + Deliberately not projected out of the session bundle the way every other + kind's answer is. `position_rationales.jsonl` is canonical, not rebuildable, + and a second writer into it would be the parallel store #403 forbids — so + the review lane calls the same service the direct entry does, inside the + finalize transaction that already holds the root lock. + + `same` records a confirmation: they were asked and said the wording still + holds, which is a real act with its own date and is not a restatement. + `changed` records their new words. `skip` records nothing — the honest shape + of a question posed and not answered, matching `_condition_answers`. + + The answer supersedes exactly the event the question quoted. A head that + moved between plan and finalize fails closed rather than forking the subject: + the user answered about a statement that is no longer the current one, and + guessing which they meant is the thing this stream must never do. + """ + root = plan.get("state_root") + if not root or not os.path.isdir(root) or plan.get("route") == "snapshot_review": + return [] + session_id = str(plan.get("session_id") or "") + written = [] + for question in plan.get("question_queue") or []: + if question.get("kind") != "rationale_refresh": + continue + answer = amap[question["id"]] + choice = answer.get("choice") + offered = {option.get("value") for option in question.get("options") or []} + if choice not in ("same", "changed", "skip") or choice not in offered: + raise ReviewError( + f"rationale_refresh answer {choice!r} is not one of the choices this " + f"question offered ({', '.join(sorted(offered))})") + if choice == "skip": + continue + prior = question.get("prior_rationale") or {} + note = (answer.get("note") or "").strip() + if choice == "same" and note: + raise ReviewError( + "`same` says the reason you already recorded still holds, so there is " + "nowhere for a note to go — recording it as a confirmation would drop " + "the words and recording it as a statement would put a date on them " + "you did not choose. Answer `changed` to record new wording") + if choice == "changed" and not note: + raise ReviewError( + "a changed holding reason needs the user's own words in `note`; " + "recording that it changed without saying to what stores nothing") + subject, state_version = _resolve_rationale_subject(root, question.get("ticker") or "") + aliases = _alias_map(root).get(subject["cycle_id"], []) + report = position_rationale.append_locked( + root, subject=subject, + act="confirmation" if choice == "same" else "statement", + user_statement=None if choice == "same" else answer.get("note"), + capture_source="review", origin_id=session_id, + state_version=state_version, aliases=aliases, + expected_predecessor=prior.get("event_id")) + written.append({"question_id": question["id"], "cycle_id": subject["cycle_id"], + "event_id": report["event_id"], "status": report["status"]}) + return written + + def cmd_finalize(args): root = os.path.abspath(os.path.expanduser(args.root or session.default_root())) with session.finalize_transaction(root, args.session_id) as transaction: @@ -5561,6 +5888,37 @@ def cmd_finalize(args): result, projection, projection_error = transaction.commit_bundle( bundle, private_md, public_md, private_html, persist=bool(plan.get("persist")) ) + # After the bundle commits, inside the same transaction: a rationale is + # canonical rather than projected, so it is written by the one append + # service and is idempotent on this session — a finalize retry returns + # the same events instead of resurrecting them over anything newer. + # + # Receipted separately rather than allowed to fail the review, which is + # the shape the owner ruled for the analogous rationale/condition pair: + # two independent outcomes, two receipts, and a card the user has + # already been shown is not discarded because a statement could not be + # attached to a position. The reverse also holds — a refused rationale + # is never reported as recorded. + try: + # `commit_bundle` releases the projection lock on return, so this + # takes it again rather than trusting the finalize transaction to + # still hold it. Without this the append runs unlocked: a concurrent + # `record-rationale` landing between the head read and the write + # forks the subject permanently, and `expected_predecessor` cannot + # see it because it was compared against the older head. + with session.projection_transaction(root) as rationale_root: + rationale_events = _record_rationale_answers( + dict(plan, state_root=rationale_root), answers, + thesis.validate_required_answers(plan, answers, + allow_commitment_missing=True)) + rationale_error = None + except (ReviewError, position_rationale.PositionRationaleError, + portfolio_basis.PortfolioBasisError, ledger.LedgerIntegrityError) as exc: + # Its callee resolves a subject against the book, so the book's own + # refusals belong here too. Anything escaping this leaves a committed + # card with no JSON receipt at all, which an agent reads as a failed + # finalize. + rationale_events, rationale_error = [], str(exc) # A no-op idempotent retry writes nothing and legacy sessions may lack the # HTML artifact; emit its path only when the file is really there so the # delivery contract's markdown fallback triggers instead of file-not-found. @@ -5570,7 +5928,11 @@ def cmd_finalize(args): "public_card": os.path.join(result["path"], "card-public.md"), "private_card_html": html_path if os.path.isfile(html_path) else None, "projection": projection, "projection_error": projection_error, - "recoverable": bool(projection_error)}) + "recoverable": bool(projection_error), + # #403: what the review recorded about why the user still holds a + # position, and — never silently — what it could not. + "rationale_events": rationale_events, + "rationale_error": rationale_error}) def cmd_resume(args): @@ -7286,6 +7648,66 @@ def _row(ticker, holding, diag): return diagnosed, residual +def cmd_record_rationale(args): + """``record-rationale``: state why you still hold a position, outside a review. + + #403's direct entry. The user's explicit statement is the whole + authorization — no card, no rule selection, no question queue, no pending + session and no second confirmation. It is deliberately not `capture`, which + requires a pending light-tier plan and appends into the rebuildable thesis + projection; this writes the canonical stream `position_rationale` owns. + + Read and write happen under one lock, `cmd_refresh`'s shape: the book that + resolves the subject and the book named on the stored row must be the same + book, or a rationale could be attached to a position that moved while the + command was running. ``--expect`` carries a head the caller read earlier and + fails closed if something landed in between, rather than forking the subject. + """ + root = os.path.abspath(os.path.expanduser(args.root or session.default_root())) + ticker = (args.ticker or "").strip() + if not ticker: + raise ReviewError("--ticker is required") + statement = args.statement + if args.confirm and statement is not None: + raise ReviewError( + "--confirm says the reason you already recorded still holds; supplying " + "--statement as well would restate it on a day you did not say it. Use one") + if not args.confirm and not (statement or "").strip(): + raise ReviewError( + "--statement is required: this records your own words for why you still " + "hold the position, and an empty one records nothing") + + with session.projection_transaction(root) as locked_root: + subject, state_version = _resolve_rationale_subject(locked_root, ticker) + aliases = _alias_map(locked_root).get(subject["cycle_id"], []) + report = position_rationale.append_locked( + locked_root, subject=subject, + act="confirmation" if args.confirm else "statement", + user_statement=None if args.confirm else statement, + stated_at=args.stated_at, capture_source="direct", + state_version=state_version, aliases=aliases, + expected_predecessor=args.expect) + view = position_rationale.query(locked_root, subject["cycle_id"], aliases=aliases) + + effective = view.get("effective") or {} + _emit({ + "status": report["status"], + "event_id": report["event_id"], + "ticker": ticker, + "cycle_id": subject["cycle_id"], + # What is in force now, echoed back so the user sees what the record says + # rather than trusting that the write meant what they intended. + "effective_statement": effective.get("user_statement"), + "effective_since": effective.get("stated_at"), + "change": view.get("change"), + "statements_recorded": view.get("total_count"), + # `recorded` means the words are durable. It is deliberately not a claim + # that anything is being monitored: no condition is created here, and a + # receipt that blurred the two would promise a watcher that does not exist. + "monitoring": "none requested; this records your reason, not a condition to watch", + }) + + def cmd_positions(args): """Read-only current-book outlet (#561): "what do I currently hold," asked away from any review, `consider` premise, or `refresh` snapshot. @@ -7734,6 +8156,22 @@ def build_parser(): "(schemas/book-refresh.schema.json). Omit for the " "read-only difference and its pending confirmations.") refresh.set_defaults(func=cmd_refresh) + record = sub.add_parser( + "record-rationale", + help="state why you still hold a position, without running a review (#403)") + record.add_argument("--ticker", required=True, + help="the position you are talking about; resolved against the recorded book") + record.add_argument("--statement", help="your own words, stored byte for byte") + record.add_argument("--confirm", action="store_true", + help="the reason already recorded still holds; records that you were " + "asked and said so, without restating the wording") + record.add_argument("--stated-at", dest="stated_at", + help="the day the statement applies; defaults to today") + record.add_argument("--expect", dest="expect", + help="the event id you read as current; fails closed if it moved") + record.add_argument("--root") + record.set_defaults(func=cmd_record_rationale, paths=None) + positions = sub.add_parser( "positions", help="read-only per-position diagnosis of the recorded current book; no " @@ -7772,7 +8210,8 @@ def main(): args.func(args) except (ReviewError, session.SessionError, thesis.ThesisError, card_renderer.RenderError, question_surface.QuestionSurfaceError, book_refresh.RefreshError, - snapshot_adapter.SnapshotError) as exc: + snapshot_adapter.SnapshotError, position_rationale.PositionRationaleError, + portfolio_basis.PortfolioBasisError) as exc: _emit({"status": "error", "error": str(exc)}) return 2 return 0 diff --git a/skills/fomo-kernel/flows/first-review.md b/skills/fomo-kernel/flows/first-review.md index d20a7991..5958d26f 100644 --- a/skills/fomo-kernel/flows/first-review.md +++ b/skills/fomo-kernel/flows/first-review.md @@ -8,11 +8,13 @@ Use when the Review Plan has `route=first_review`. This step is now gated rather than trusted (#623). `preview` and `finalize` refuse a card that would tell the user their prices could not be retrieved when `input.price_feed.recovery.attempted` is false, because that card is not a disclosure — it is a review whose every weight came from cost basis when it did not have to, and the user can do nothing about it. If the sources genuinely publish nothing for these instruments, that is a real outcome: rerun `prepare --prices-unavailable ""` and the degraded card is delivered as a stated dead end. Declaring is one command and asks the user for nothing, so this is a gate on a step you were told to take, not a stall. +**0c. What they already told you about why they hold these.** `state_snapshot.position_rationales` carries the reason the user last recorded for each position, oldest first, with `position_rationales_summary` stating what the cap held back. Read it before asking anything about a position: these are their own words, and asking a question they have already answered is the failure #403 exists to end. If a `rationale_refresh` question is queued, it already quotes the relevant one — never paraphrase the quote, and never present it as a claim the reason is still true. An entry whose `forked` is non-empty is a damaged subject: the order shown is the reader's, not the record's, so do not present it as their history. Nothing here is a card section — whether any of it earns a question this week is your judgment against everything else competing for the budget. + **1. Set expectations briefly.** One or two sentences: the engine computed these numbers locally, and the review needs the user's motive before it can reach a conclusion. **2. Ask every required question in `question_queue`, in order, once.** Follow `references/interaction-delivery.md` for presentation. A first review carries three to five highest-information items, possibly including an `initial_thesis` capture asking, for an un-thesised holding, whether the entry followed a plan, chased momentum, came from an external recommendation, or had no clear thesis. -For `add_thesis`, `initial_thesis`, `headline_motive`, and `exit_consistency`, bind a grounded private surface through `review.py resume --question-surfaces` and present exactly what comes back; on any failure keep the engine fallback. An `exit_consistency` question is the answerable form of the Block-3 exit-opportunity-cost observation — it asks whether the early exits it names were a deliberate rule, an emotional reaction, or an external constraint, and the read-only panel yields to it when queued. A recent-exit question stays engine-rendered, captures a reason without judging the outcome, and saves `skip` so it is not asked again. +For `add_thesis`, `initial_thesis`, `headline_motive`, and `exit_consistency`, bind a grounded private surface through `review.py resume --question-surfaces` and present exactly what comes back; on any failure keep the engine fallback. An `exit_consistency` question is the answerable form of the Block-3 exit-opportunity-cost observation — it asks whether the early exits it names were a deliberate rule, an emotional reaction, or an external constraint, and the read-only panel yields to it when queued. A recent-exit question stays engine-rendered, captures a reason without judging the outcome, and saves `skip` so it is not asked again. `rationale_refresh` stays engine-rendered too: its stem is a dated quote of the user's own recorded words, and an agent rephrasing of a verbatim statement is exactly what this stream exists to prevent. The queue is the engine's ranking by impact, so do not merge, add, skip, or reorder it, and do not change canonical options or payload gates. A quoted thesis keeps its engine-declared voice. One fact-grounded observation per review may enter `observations` without changing the queue. diff --git a/skills/fomo-kernel/flows/weekly-review.md b/skills/fomo-kernel/flows/weekly-review.md index 84d6ae1d..1b794e7e 100644 --- a/skills/fomo-kernel/flows/weekly-review.md +++ b/skills/fomo-kernel/flows/weekly-review.md @@ -8,11 +8,13 @@ Use when the Review Plan has `route=weekly_review`. **0b. Check what is due, in the same pass.** For each entry in `state_snapshot.condition_slots_due`, run its **frozen `query`** — never a yes/no restatement, never a question of your own — and transcribe the result into the envelope in `references/condition-slots.md`, then rerun `prepare --condition-checks ` and continue on the session it returns. A figure you cannot find is `lookup_status: "failed"` with a reason, which is a legitimate outcome; never carry the previous period's number forward as though it were this one. **Never assert a verdict in prose**: you supply evidence, the engine performs the comparison, and an event's verdict is the user's answer. The list is capped and `condition_slots_summary` says what was held back — do not present it as the user's whole record. Send the same array again in `answers.condition_checks`, complete and unchanged: a reading that changed, or one ingested at prepare and then omitted, fails closed. One entry per condition *line* — a superseded `slot_id` and the live head are the same condition. Never put `user_response` or `basis_resolution` on an envelope; those record what the user answered, and the engine writes them. +**0c. What they already told you about why they hold these.** `state_snapshot.position_rationales` carries the reason the user last recorded for each position, oldest first, with `position_rationales_summary` stating what the cap held back. Read it before asking anything about a position: these are their own words, and asking a question they have already answered is the failure #403 exists to end. If a `rationale_refresh` question is queued, it already quotes the relevant one — never paraphrase the quote, and never present it as a claim the reason is still true. An entry whose `forked` is non-empty is a damaged subject: the order shown is the reader's, not the record's, so do not present it as their history. Nothing here is a card section — whether any of it earns a question this week is your judgment against everything else competing for the budget. + **1. Read `state_snapshot` from the Review Plan** — not the `~/.trade-coach` directory. If `state_snapshot.cadence.tier == "light"`, stop here and follow `flows/light-capture.md`. **2. Open by reconciling against `prior_commitment`;** when it is null, say the previous review ended with no committed rule. Record that the memory was actually presented (`references/ux-receipt.md`). Continuity is the reason this product exists, so it has to be visible before anything new. The renderer opens the card with the committed rule and that metric's then/now values verbatim — interpret the movement in the mirror rather than computing a delta yourself; a condition-anchored commitment reconciles the same way, against what step 0b's check found. Conditions the user wrote are their own standing conditions and are never checked-and-fine unless a lookup said so (`references/condition-slots.md`). -**3. Ask only what is in `question_queue`, once,** following `references/interaction-delivery.md`. Prepare already deduplicated against active theses and add counts, so do not ask raw engine `thesis_questions` again. For `add_thesis`, `headline_motive`, `exit_consistency`, `condition_crossing`, and `condition_basis`, bind a grounded private surface through `review.py resume --question-surfaces` and present exactly what comes back; on failure keep the engine fallback. A `condition_crossing` stem must carry **both** sides — one sentence for acting on the line, one for not — built only from the criterion, the line, and what the lookup returned. `due_revisit`, `rule_breach`, and recent-exit capture stay engine-rendered. When the queue carries `prior_exit_reason` or a matured revisit, record the matching memory presentation after showing that context. +**3. Ask only what is in `question_queue`, once,** following `references/interaction-delivery.md`. Prepare already deduplicated against active theses and add counts, so do not ask raw engine `thesis_questions` again. For `add_thesis`, `headline_motive`, `exit_consistency`, `condition_crossing`, and `condition_basis`, bind a grounded private surface through `review.py resume --question-surfaces` and present exactly what comes back; on failure keep the engine fallback. A `condition_crossing` stem must carry **both** sides — one sentence for acting on the line, one for not — built only from the criterion, the line, and what the lookup returned. `due_revisit`, `rule_breach`, and recent-exit capture stay engine-rendered. `rationale_refresh` stays engine-rendered too: its stem is a dated quote of the user's own recorded words, and an agent rephrasing of a verbatim statement is exactly what this stream exists to prevent. When the queue carries `prior_exit_reason` or a matured revisit, record the matching memory presentation after showing that context. The queue is the engine's ranking, so do not change route, kind, priority, required status, canonical choices, payload gates, facts, or identities. A quoted thesis keeps its engine-declared voice, and one fact-grounded observation may enter `observations` without changing the queue. diff --git a/skills/fomo-kernel/references/agent-boundaries.md b/skills/fomo-kernel/references/agent-boundaries.md index a2141601..d4542494 100644 --- a/skills/fomo-kernel/references/agent-boundaries.md +++ b/skills/fomo-kernel/references/agent-boundaries.md @@ -21,7 +21,7 @@ The agent may not: - Upload a statement or screenshot for OCR. Snapshot transcription stays local; the engine accepts only the normalized JSON envelope through `review.py`. - Ask whether a holdings view covers the user's whole account, treat which kind of source recorded the book as deciding whether it may anchor, or claim that a later transaction import reconciles a fresh broker view. Every accepted source records the book at the time it arrives; ledger-derived current holdings stay canonical, and a newer holdings view reaches the recorded book through `refresh`. - Put private data into the public card. -- Call another `engine/*` script or import engine modules directly. Invoke the engine through the `engine/review.py` CLI (`prepare`, `resume`, `preview`, `finalize`, `capture`, `consider`, `refresh`, `positions`, `render`, `repair-projections`, `set-cap`, `mute-rule`, `add-cash`, `resolve-market-data`, `doctor`) so lifecycle validation, required-question gates, and canonical session state stay authoritative. `add-cash` stays inside the lifecycle: it recomputes one prepared session with the anchor the user supplied at the card beat and refuses if anything but the anchor moved (`references/data-contract.md`). `capture` and `consider` are the two sanctioned append paths outside that lifecycle — `capture` only for a light-tier review (`flows/light-capture.md`), `consider` only for a trade the user has not placed yet (`references/trade-consequence.md`). `resolve-market-data` and `positions` are neither: `resolve-market-data` retrieves current market facts into a `references/price-feed.md` envelope, and `positions` prints the per-position diagnosis of the recorded book (#561, `references/freeform-answers.md`) — both write no session, ledger, or evaluation row. `prepare` and `consider` resolve their own facts, so reach for `resolve-market-data` only to inspect what retrieval produced, or to capture an envelope for a machine that cannot reach the provider. +- Call another `engine/*` script or import engine modules directly. Invoke the engine through the `engine/review.py` CLI (`prepare`, `resume`, `preview`, `finalize`, `capture`, `consider`, `refresh`, `positions`, `record-rationale`, `render`, `repair-projections`, `set-cap`, `mute-rule`, `add-cash`, `resolve-market-data`, `doctor`) so lifecycle validation, required-question gates, and canonical session state stay authoritative. `add-cash` stays inside the lifecycle: it recomputes one prepared session with the anchor the user supplied at the card beat and refuses if anything but the anchor moved (`references/data-contract.md`). `capture`, `consider` and `record-rationale` are the three sanctioned append paths outside that lifecycle — `capture` only for a light-tier review (`flows/light-capture.md`), `consider` only for a trade the user has not placed yet (`references/trade-consequence.md`). `resolve-market-data` and `positions` are neither: `resolve-market-data` retrieves current market facts into a `references/price-feed.md` envelope, and `positions` prints the per-position diagnosis of the recorded book (#561, `references/freeform-answers.md`) — both write no session, ledger, or evaluation row. `prepare` and `consider` resolve their own facts, so reach for `resolve-market-data` only to inspect what retrieval produced, or to capture an envelope for a machine that cannot reach the provider. - Argue a considered trade's case from anything but `consider`'s output. The engine states the consequence; the agent may add judgment about the thesis, the valuation, or the timing, but every such claim carries its own label, and the risks the engine does not measure are named rather than passed over. Silence about what was not checked reads as a clean bill of health. What this particular answer owes is not a list to remember: the response's own `challenge` block computes it per call (`references/trade-consequence.md`, "What the answer owes"). If a new observation could overturn the top behavioral leak, add it to `observations` and rerun preview. Do not edit the engine artifact. That keeps analytical flexibility while every conclusion change still passes the same validator and renderer. diff --git a/skills/fomo-kernel/references/interaction-delivery.md b/skills/fomo-kernel/references/interaction-delivery.md index 6a0111f7..d84fadc9 100644 --- a/skills/fomo-kernel/references/interaction-delivery.md +++ b/skills/fomo-kernel/references/interaction-delivery.md @@ -22,7 +22,7 @@ Immediately after `prepare`, resolve exactly one adapter from what this host can ## Author a question surface, or use the engine's -Every queue row arrives with display-ready `question` and `options`. `add_thesis`, `headline_motive`, `initial_thesis`, `exit_consistency`, `condition_crossing`, and `condition_basis` may also carry a `question_opportunity`, which invites you to write a grounded, specific stem instead of the generic one. `due_revisit`, `rule_breach`, and recent-exit `revisit` stay engine-rendered. +Every queue row arrives with display-ready `question` and `options`. `add_thesis`, `headline_motive`, `initial_thesis`, `exit_consistency`, `condition_crossing`, and `condition_basis` may also carry a `question_opportunity`, which invites you to write a grounded, specific stem instead of the generic one. `due_revisit`, `rule_breach`, and recent-exit `revisit` stay engine-rendered. `rationale_refresh` stays engine-rendered too: its stem is a dated quote of the user's own recorded words, and an agent rephrasing of a verbatim statement is exactly what this stream exists to prevent. A `condition_crossing` is the one kind where the engine's own fallback is deliberately the flattest sentence it can write, because the question needs something a template cannot produce: **one sentence for acting on the line and one for not**. Ground both in `context.condition.criterion` and `context.condition.evidence` — the user's own words and what the lookup returned — and nothing else. The engine's comparison is already made; what the question buys is the chance for a user who knows the figure is misleading to say so, which only exists if the stem gives that side real weight. diff --git a/skills/fomo-kernel/schemas/position-rationale.schema.json b/skills/fomo-kernel/schemas/position-rationale.schema.json new file mode 100644 index 00000000..96dd6517 --- /dev/null +++ b/skills/fomo-kernel/schemas/position-rationale.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://fomo-kernel.local/schemas/position-rationale.schema.json", + "title": "Fomo Kernel Position Rationale", + "description": "One event in the append-only chain recording why the user says they still hold one position cycle (#403). The readable contract; skills/fomo-kernel/engine/position_rationale.py holds the enforcement, because the offline suite carries no jsonschema dependency. The durable row stored in /position_rationales.jsonl, and there is no agent-facing input shape: every field here is engine-assigned or is the user's own words passed through untouched. Nothing is ever rewritten — a changed reason is a new event naming the one it follows, so the record keeps what was said and when it stopped being current, rather than the current answer alone. Three fields differ from the shape first frozen on #403, and they are one change rather than three: `supersedes` enters the content address, idempotency moves from id collision to head comparison, and `act` replaces a stored `initial | changed | no_change`. Each is there because the frozen shape loses a statement the user made while the writer reports success; the module docstring carries the reasoning and the receipts.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "event_id", "subject", "act", "stated_at", "recorded_at", + "supersedes", "voice", "capture_source", "state_version"], + "properties": { + "schema_version": { + "const": 1, + "description": "This stream's own shape. Written on every row because rows are append-only and a later row may be read beside a much older one." + }, + "event_id": { + "type": "string", + "description": "Engine-assigned identity, content-addressed over every field except `recorded_at`. `supersedes` is part of that address, which is what makes a user who returns to an earlier reason (A, then B, then A, then B) produce four distinct events instead of silently dropping the fourth onto the second. Never supplied by a caller and never recomputed on read: a row carries the id it was written with." + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["cycle_id", "ticker", "market", "currency"], + "description": "The active position cycle this statement is about. All four keys are required: joining a rationale on ticker alone would cross an unproven sale-and-rebuy boundary, which is exactly the join #403 forbids. The subject is resolved from the recorded book by the entry point, which also owns #403's route rules — one active cycle appends, no active position refuses, an ambiguous ticker fails closed. This module validates the shape and refuses a partial subject; it does not read the book, so it cannot itself confirm the cycle exists. A reader follows engine-proven relinks so a cycle whose start moved (#563) still finds statements written under its former id.", + "properties": { + "cycle_id": {"type": "string", "description": "The position cycle. A sold and rebought position is a different cycle and inherits nothing."}, + "ticker": {"type": "string"}, + "market": {"type": "string"}, + "currency": {"type": "string"} + } + }, + "act": { + "enum": ["statement", "confirmation"], + "description": "What the user did, and deliberately not `initial | changed | no_change`. `statement`: they said why they hold it, and the words are on this row. `confirmation`: they were asked and said nothing had changed, so the prior wording stays in force without being copied onto a day they did not say it. Whether an event is the first on its subject is read from `supersedes`, not stored — `thesis.build_snapshot_cycle_relinks` re-parents a subject retroactively by design (Front A of the same issue), so a frozen `initial` can end up behind three earlier events with nothing detecting the disagreement." + }, + "user_statement": { + "type": "string", + "minLength": 1, + "description": "The user's own words, byte for byte: not stripped, not normalized, not tidied into product phrasing. Required for `statement` and refused for `confirmation`, both enforced. Repeating the head's exact act is an idempotent no-op, as the accepted append semantics require; what the predecessor in the address buys is the *non-immediate* return to an earlier reason — the fourth event of A→B→A→B is a real re-statement with its own id rather than a collision with the second." + }, + "supersedes": { + "type": ["string", "null"], + "description": "The event id of this subject's head at write time, or null for the first event on the subject. The immediate predecessor, never 'the prior substantive wording': pointing past confirmations would make two confirmations of one statement byte-identical, and which statement is currently in force is a derived walk back through them. Always present — a constant key cannot shift another row's digest the way an intermittent one would. A null here is what `initial` used to claim, carried where a later discovery cannot falsify it; two nulls on one subject read honestly as two origins, one found later." + }, + "stated_at": { + "type": "string", + "format": "date", + "description": "The day the statement applies, supplied by the caller rather than taken from a clock inside the engine. Part of the content address." + }, + "recorded_at": { + "type": "string", + "description": "When the kernel learned it. Stamped after the identity is computed and never part of it, so a replay of the same act reproduces the same id rather than growing the file by one row per retry." + }, + "voice": { + "const": "user_verbatim", + "description": "What kind of source this row is. Constant for this stream by construction: an agent interpretation belongs in the thesis projection, and a legacy classification is never upgraded into a verbatim statement." + }, + "capture_source": { + "enum": ["direct", "review", "light_capture"], + "description": "Which entrance produced it. `direct`: the user said it outside any review. `review`: a rationale-refresh question collected it. `light_capture`: the legacy cadence-light route, which keeps its own writer until its migration slice lands. A `review` row is additionally idempotent on its own session and subject, so a re-finalized session cannot resurrect its statement over a newer direct one." + }, + "origin_id": { + "type": "string", + "description": "The producing interaction or session, when one exists. Omitted entirely when it does not, rather than sent as null: a key that appears only sometimes moves the digest of every row that lacks it." + }, + "state_version": { + "type": "string", + "description": "The recorded book that resolved the subject. Required — a statement about a position is a statement about the book that said the position was held, and a rationale that cannot name it cannot be reconciled later." + }, + "condition_ref": { + "type": "string", + "description": "An optional link to the condition slot for this reason, when a later slice legally creates one. The pointer says a slot exists, never that it is monitored. There is deliberately no `mapping_status` field and this is settled, not an open choice: `conditions.py` owns the condition's current tier and failure reason, and the integration receipt owns the user-visible `recorded` versus `monitored` distinction — one reader, one fact. A status copied here would also rot by construction, since a condition that is blind today can become watchable next quarter through a new slot revision while this row can never be updated. Never manufactured: a reason that names nothing observable is preserved verbatim and nothing claims to be watching it." + } + } +} diff --git a/skills/fomo-kernel/schemas/review-plan.schema.json b/skills/fomo-kernel/schemas/review-plan.schema.json index 5d57dd6a..0723a1e5 100644 --- a/skills/fomo-kernel/schemas/review-plan.schema.json +++ b/skills/fomo-kernel/schemas/review-plan.schema.json @@ -201,7 +201,8 @@ "initial_thesis", "exit_consistency", "condition_crossing", - "condition_basis" + "condition_basis", + "rationale_refresh" ] }, "ticker": { @@ -450,6 +451,37 @@ "description": "The user's words, verbatim from schemas/decision-context.schema.json's `reason` (or `why_now` when only that is present). Never truncated, translated or paraphrased -- the same rule that schema puts on storing them." } } + }, + "prior_rationale": { + "type": "object", + "additionalProperties": false, + "required": ["statement", "stated_at", "event_id"], + "description": "rationale_refresh only (#403): the holding reason the user last recorded for this cycle, carried so the question quotes their own words instead of asking them to reconstruct it. A fact and never a cause -- it says they said this, on this date, and asserts nothing about whether it is still true, which is the entire point of asking. The same boundary `recalled_statement` draws just above. Absent whenever nothing is on file for the cycle: a position with no recorded reason is not a refresh, and the question is not asked at all.", + "properties": { + "statement": { + "type": "string", + "minLength": 1, + "description": "Their wording, byte for byte off the position_rationales.jsonl row. Never truncated, translated, or tidied into product phrasing -- the whole value of a self-report is that our defaults did not anticipate it." + }, + "stated_at": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "description": "The day the statement applies, shown in the stem so the quote reads as a dated record rather than a present-tense claim." + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "The event this question is asked against. The answer supersedes exactly this event; a head that moved between plan and finalize fails closed rather than forking the subject." + }, + "days_since": { + "type": ["integer", "null"], + "description": "Whole days from `stated_at` to today, or null when either date is unusable. Measured against today rather than the review's date_end, because how long since they last said it is a question about now." + }, + "statements_recorded": { + "type": "integer", + "description": "How many statements this cycle has on file, so a first reason and a fifth revision are not presented alike." + } + } } }, "additionalProperties": false diff --git a/tests/golden/copy-corpus.txt b/tests/golden/copy-corpus.txt index 741bbcde..88d25e44 100644 --- a/tests/golden/copy-corpus.txt +++ b/tests/golden/copy-corpus.txt @@ -10308,7 +10308,7 @@ copy-key coverage [zh-TW] reached 58 · unreached 0 · trivial 25 [en] reached 79 · unreached 0 · trivial 4 -registers not claimed by any scene in this corpus (74): +registers not claimed by any scene in this corpus (77): account_perf add_choices add_descriptions @@ -10364,6 +10364,9 @@ registers not claimed by any scene in this corpus (74): public_mirror public_patterns public_performance + rationale_refresh + rationale_refresh_choices + rationale_refresh_choices_descriptions review_milestone rule_breach_choices rule_breach_decisions diff --git a/tests/rationale_fixture.py b/tests/rationale_fixture.py new file mode 100644 index 00000000..bf622377 --- /dev/null +++ b/tests/rationale_fixture.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Shared position-rationale fixture (#450, #403 Front B). + +One seeded stream every suite that reads rationales can build against, so the +integration slice, #450's later multi-source reader and this stream's own tests +assert over the *same* history instead of each inventing one. A fixture that +each suite rolls itself is how two readers end up disagreeing about what the +record says — the failure this stream exists to prevent, one layer up. + +Deliberately not a golden file: the stream is content-addressed, so a checked-in +`.jsonl` would pin event ids and break on any legitimate contract change, while +seeding through the real writer proves the ids are reachable. Follows +`tests/offline_posture.py`'s precedent — a plain module under `tests/`, imported +with `sys.path.insert(0, os.path.dirname(...))`. + +Usage: + + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import rationale_fixture + seeded = rationale_fixture.seed(root) + seeded["acme"]["cycle_id"] # the subject with a three-event history + seeded["expect"]["acme_effective"] # what the reader must call in force + +What the history contains, and why each piece is here: + + ACME three events -- a statement, a changed reason, then a confirmation. + Exercises the derived `changed` classification and the walk back + through a confirmation to the wording still in force. + WIDGET one statement. The shortest real history; a reader that only works + past two events fails here. + RELINKED statements written under a cycle id that #563's later-history upgrade + has since moved. A reader that matches the current id alone reports + nothing for it, which is exactly the "remembered, then silently gone" + failure #403 names. + ORPHAN an active-looking subject with no rationale at all. A reader must + return an empty history rather than raising or inventing one. +""" +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "skills", "fomo-kernel", "engine")) +import position_rationale as pr # noqa: E402 + +ACME = {"cycle_id": "ACME#2026-06-30#1", "ticker": "ACME", + "market": "US", "currency": "USD"} +WIDGET = {"cycle_id": "WIDGET#2026-05-04#1", "ticker": "WIDGET", + "market": "US", "currency": "USD"} +# The id the statements were written under, before #563 moved the cycle start. +RELINKED_FORMER = "GLOBEX#2026-04-01#1" +RELINKED = {"cycle_id": "GLOBEX#2025-11-12#1", "ticker": "GLOBEX", + "market": "US", "currency": "USD"} +ORPHAN = {"cycle_id": "INITECH#2026-07-02#1", "ticker": "INITECH", + "market": "US", "currency": "USD"} + +ACME_FIRST = "配息很穩,我當存股在放" +ACME_CURRENT = "改成看它買回庫藏股的力道" +WIDGET_ONLY = "entered on the new plant coming online" +RELINKED_FIRST = "held since before the history upgrade" +RELINKED_CURRENT = "and the reason has not really changed since" + + +def seed(root, *, state_version="sv-fixture-1"): + """Write the shared history into ``root`` and return what it contains. + + Everything goes through the real writer, so ids, chain links and the + `stated_at` ordering are the ones production would produce. + """ + def say(subject, text, day, **kw): + return pr.append(root, subject=subject, act="statement", user_statement=text, + stated_at=day, capture_source=kw.pop("source", "direct"), + state_version=state_version, **kw) + + acme_first = say(ACME, ACME_FIRST, "2026-07-01") + acme_changed = say(ACME, ACME_CURRENT, "2026-07-14") + acme_confirmed = pr.append(root, subject=ACME, act="confirmation", + stated_at="2026-07-28", capture_source="review", + origin_id="2026-07-28__fixture", state_version=state_version) + widget = say(WIDGET, WIDGET_ONLY, "2026-06-02") + + former = {**RELINKED, "cycle_id": RELINKED_FORMER} + relinked_first = say(former, RELINKED_FIRST, "2026-04-05") + relinked_current = pr.append( + root, subject=RELINKED, act="statement", user_statement=RELINKED_CURRENT, + stated_at="2026-07-20", capture_source="direct", state_version=state_version, + aliases=[RELINKED_FORMER]) + + return { + "root": root, + "acme": {**ACME, "events": [acme_first, acme_changed, acme_confirmed]}, + "widget": {**WIDGET, "events": [widget]}, + "relinked": {**RELINKED, "aliases": [RELINKED_FORMER], + "events": [relinked_first, relinked_current]}, + "orphan": dict(ORPHAN), + "state_version": state_version, + # What any correct reader must report. A consumer asserting against these + # rather than against its own copy is the point of the fixture. + "expect": { + "acme_effective": ACME_CURRENT, + "acme_change": "no_change", # latest act is the confirmation + "acme_total": 3, + "widget_effective": WIDGET_ONLY, + "widget_change": "initial", + "relinked_effective": RELINKED_CURRENT, + "relinked_total": 2, # only with the alias supplied + "relinked_total_without_alias": 1, + "orphan_total": 0, + }, + } diff --git a/tests/run_all.py b/tests/run_all.py index d8d30b33..539e96dd 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -32,6 +32,16 @@ ("Answer provenance gate (#414, Wave A)", "tests/test_answer_provenance.py"), ("Visible evaluation challenge (#479 Wave B)", "tests/test_evaluation_challenge.py"), ("Behavior verdicts (#446)", "tests/test_verdicts.py"), + # #403 Front B: the canonical stream, its fold and the #450 bounded reader. + # No CLI and no review integration yet — a row here is not memory until a + # later slice proves a reader quotes it, and this suite says so rather than + # asserting the capability it does not have. + ("Position rationale stream (#403 Front B)", "tests/test_position_rationale.py"), + # #403 integration: the direct entry and the next review's reader. Its first + # test is the slice's acceptance condition — a statement recorded outside a + # review is quoted back by the next one — and until that passes the stream is + # a writer without a reader (#429), not memory. + ("Rationale direct entry and next-review reader (#403)", "tests/test_rationale_review.py"), ("Snapshot-anchored ledger", "tests/test_ledger.py"), ("PortfolioBasis current-book contract (#484)", "tests/test_portfolio_basis.py"), ("Exit revisit and swap", "tests/test_revisit.py"), diff --git a/tests/test_coach_data_cli.py b/tests/test_coach_data_cli.py index af4be9ad..663f3063 100644 --- a/tests/test_coach_data_cli.py +++ b/tests/test_coach_data_cli.py @@ -273,6 +273,37 @@ def test_condition_checks_projection_is_status_export_and_reset_managed(): assert not os.path.exists(checks_path) +def test_position_rationales_are_status_export_and_reset_managed(): + """#403: position_rationales.jsonl holds the user's own words for why they + still hold a position -- the most personal thing this product stores, and + the one a user is most likely to want a copy of or gone. The registry is how + every data-control operation finds it (mirrors the condition_checks.jsonl + and verdicts.jsonl tests above, #412/#446).""" + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "position_rationales.jsonl") + with open(path, "w", encoding="utf-8") as f: + f.write('{"event_id":"position-rationale-0","act":"statement",' + '"subject":{"cycle_id":"ACME#2026-06-30#1"}}\n') + + status = json.loads(_run("data-status", "--root", tmp).stdout) + by_name = {entry["name"]: entry for entry in status["files"]} + assert status["present_count"] == 1 + assert by_name["position_rationales.jsonl"]["exists"] + assert by_name["position_rationales.jsonl"]["lines"] == 1 + + out_zip = os.path.join(tmp, "backup.zip") + exported = _run("data-export", "--root", tmp, "--out", out_zip) + assert exported.returncode == 0, exported.stderr + assert json.loads(exported.stdout)["included"] == ["position_rationales.jsonl"] + with zipfile.ZipFile(out_zip) as zf: + assert zf.namelist() == ["position_rationales.jsonl"] + + reset = _run("data-reset", "--root", tmp, "--confirm") + assert reset.returncode == 0, reset.stderr + assert json.loads(reset.stdout)["deleted"] == [path] + assert not os.path.exists(path) + + # ─── E. registry completeness: the next omission fails the suite (#452) ──── # # DATA_FILES stays hand-authored -- each entry's description is user-facing diff --git a/tests/test_episode_checkers.py b/tests/test_episode_checkers.py index 784380d5..4534c225 100644 --- a/tests/test_episode_checkers.py +++ b/tests/test_episode_checkers.py @@ -474,7 +474,8 @@ def test_unmapped_never_turns_into_a_failure(): # `state:` proofs have something truthful to check against. CONSUMER_PLAN = {"state_snapshot": {"thesis_states": [], "headline_motive_events": [], "due_revisits": [], "problem_stats": {}, - "condition_slots_due": []}} + "condition_slots_due": [], + "position_rationales": []}} def _with_consumers(consumers, unwired): diff --git a/tests/test_position_rationale.py b/tests/test_position_rationale.py new file mode 100644 index 00000000..5841c79e --- /dev/null +++ b/tests/test_position_rationale.py @@ -0,0 +1,807 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""The canonical position-rationale stream (#403 Front B). Offline, no pytest. + +What this file settles: + + A. A user's own words round-trip byte for byte, and a changed reason is a new + event rather than a rewrite of the old one. + B. A retry is a no-op and a revert is not. These are the two halves the frozen + contract could not separate, and getting one wrong loses a statement the + user made while the writer reports success. + C. A confirmation says the prior wording still holds without restating it, and + two confirmations on different days are two events. + D. Nothing is joined across subjects, and a corrupt line costs one row rather + than the record — and is counted rather than swallowed. + E. The bounded reader (#450) reports its truncation honestly, follows + engine-proven cycle relinks, derives the frozen `initial | changed | + no_change` vocabulary, and computes the span, the totals and the effective + statement from the untruncated chain. + F. Three ambiguities fail closed rather than resolving to a winner the user + never nominated: a forked subject, a moved expected predecessor, and a + review session returning with different words under an id it already used. + +The three mutations this file exists to fail, per docs/development-guide.md §2: + + 1. Drop ``supersedes`` from the content address (#403's pre-amendment shape) — + ``test_a_reason_the_user_returns_to_is_not_swallowed_as_a_retry`` fails, + because the user's fourth statement hashes onto their second. + 2. Replace head comparison with id-collision idempotency — + ``test_a_write_that_landed_before_its_receipt_is_not_stored_twice`` fails. + 3. Store ``initial``/``changed`` and read it back instead of deriving position + from the pointer — ``test_where_an_event_sits_in_its_chain_is_derived`` fails + once a later event is discovered ahead of it, which Front A's relink does by + design. + +Division of labour: this file owns the stream, the fold and the bounded reader. +The direct CLI entry, the review collector and the light-capture migration are +later slices and are not exercised here — until one lands, a row in this file is +not yet memory, and no test here may claim otherwise. + +Run: + python3 tests/test_position_rationale.py +""" +import json +import os +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +ENGINE = os.path.join(ROOT, "skills", "fomo-kernel", "engine") +sys.path.insert(0, ENGINE) +import position_rationale as pr # noqa: E402 +sys.path.insert(0, HERE) +import rationale_fixture # noqa: E402 + +ACME = {"cycle_id": "ACME#2026-06-30#1", "ticker": "ACME", + "market": "US", "currency": "USD"} +WIDGET = {"cycle_id": "WIDGET#2026-06-30#1", "ticker": "WIDGET", + "market": "US", "currency": "USD"} +# A rebought ACME: same ticker, different cycle. Nothing may cross this line. +ACME_2 = {"cycle_id": "ACME#2026-07-20#2", "ticker": "ACME", + "market": "US", "currency": "USD"} + + +def _say(root, subject, text, *, stated_at="2026-07-01", source="direct", + origin_id=None, state_version="sv-1"): + return pr.append(root, subject=subject, act="statement", user_statement=text, + stated_at=stated_at, capture_source=source, origin_id=origin_id, + state_version=state_version) + + +def _confirm(root, subject, *, stated_at="2026-07-02", state_version="sv-1"): + return pr.append(root, subject=subject, act="confirmation", stated_at=stated_at, + capture_source="direct", state_version=state_version) + + +def _rows(root): + with open(pr._rationale_path(root), encoding="utf-8") as handle: + return [json.loads(line) for line in handle if line.strip()] + + +# ─────────────────── A. the words, and what supersedes what ─────────────────── + +def test_the_users_own_words_round_trip_byte_for_byte(): + """Not stripped, not tidied, not normalized. A stream that rephrases the + user is a stream of the product's words, and the whole reason to store a + self-report verbatim is that our defaults did not anticipate it.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + text = " 管理層換人之後我才進場的,想再看兩季 " + out = _say(root, ACME, text) + assert out["status"] == "appended" + assert _rows(root)[0]["user_statement"] == text, "including the spaces" + assert pr.query(root, ACME["cycle_id"])["effective"]["user_statement"] == text + + +def test_a_changed_reason_is_a_new_event_that_names_the_one_it_follows(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + first = _say(root, ACME, "for the dividend") + second = _say(root, ACME, "for the buyback") + rows = _rows(root) + assert len(rows) == 2, "a changed reason appends; it never rewrites" + assert rows[0]["user_statement"] == "for the dividend", "the old wording stays" + assert rows[1]["supersedes"] == first["event_id"] + assert rows[0]["supersedes"] is None, ( + "a first event names no predecessor, and that -- not a stored label -- " + "is what says it is first") + assert pr.query(root, ACME["cycle_id"])["effective"]["event_id"] == \ + second["event_id"] + + +def test_where_an_event_sits_in_its_chain_is_derived(): + """Mutation 3's target. Front A's `build_snapshot_cycle_relinks` re-parents a + subject retroactively, so an event honestly written "first" can end up behind + earlier ones on the merged subject. Position must therefore be a property of + the chain read now, never a label frozen at write time. + + Simulated here the way a relink produces it: a row for the same subject that + the file did not contain when the later ones were written. + """ + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "for the dividend") + _say(root, ACME, "for the buyback") + chain = pr.chain_for(_rows(root), ACME["cycle_id"]) + assert [row["supersedes"] is None for row in chain] == [True, False] + + discovered = pr.build_event( + subject=ACME, act="statement", user_statement="the original reason, found later", + stated_at="2026-05-01", capture_source="light_capture", state_version="sv-0") + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write(json.dumps(discovered, ensure_ascii=False, sort_keys=True) + "\n") + + chain = pr.chain_for(_rows(root), ACME["cycle_id"]) + assert len(chain) == 3 + roots = [row for row in chain if row["supersedes"] is None] + assert len(roots) == 2, ( + "two chain roots read honestly as two origins, one discovered later; " + "two rows each storing 'this was the first' would read as a contradiction") + assert pr.query(root, ACME["cycle_id"])["total_count"] == 3 + + +# ─────────────────── B. retry versus revert, the load-bearing pair ─────────────────── + +def test_a_reason_the_user_returns_to_is_not_swallowed_as_a_retry(): + """Mutation 1's target, and the defect behind the owner's accepted + predecessor-linked identity. The user says A, then B, then A again, then B + again -- one + sitting, same day, same book. Without the predecessor in the content address + the fourth statement hashes identically to the second and is dropped as an + "exact retry", while the writer returns a success receipt naming the older + event. The record would then contradict the user and report that it agreed. + """ + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + a1 = _say(root, ACME, "for the dividend") + b1 = _say(root, ACME, "for the buyback") + a2 = _say(root, ACME, "for the dividend") + b2 = _say(root, ACME, "for the buyback") + assert [out["status"] for out in (a1, b1, a2, b2)] == ["appended"] * 4 + assert len({out["event_id"] for out in (a1, b1, a2, b2)}) == 4, ( + "four acts, four events; a returning reason is a real act and the " + "second B is not the first one happening again") + assert len(_rows(root)) == 4 + assert pr.query(root, ACME["cycle_id"])["effective"]["user_statement"] == \ + "for the buyback", "and what is in force is what they last said" + + +def test_a_write_that_landed_before_its_receipt_is_not_stored_twice(): + """Mutation 2's target, and the hole the predecessor pointer opens on its own. + + An exact retry must be a no-op. Id-collision cannot deliver that once the + pointer is in the address: the retry re-reads a head that is now its own row, + so it hashes to something new and the same words land twice. Idempotency is + therefore head comparison, which is what `review._append_evaluation_row` + already does for the same reason. + """ + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + first = _say(root, ACME, "for the dividend") + retry = _say(root, ACME, "for the dividend") + assert retry["status"] == "no-op" and retry["appended"] == 0 + assert retry["event_id"] == first["event_id"], ( + "the retry is told which event already records this act") + assert len(_rows(root)) == 1 + + +def test_a_review_that_is_finalized_twice_does_not_resurrect_its_statement(): + """A re-finalized session must not overwrite a newer direct statement. + + Head comparison alone would not catch this -- the head has moved on -- so the + review lane is idempotent on its own session and subject, the same key + `session._append_session_rows` uses. + """ + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "collected during the review", source="review", + origin_id="session-abc") + _say(root, ACME, "and then I changed my mind") + replay = _say(root, ACME, "collected during the review", source="review", + origin_id="session-abc") + assert replay["status"] == "no-op" + assert len(_rows(root)) == 2 + assert pr.query(root, ACME["cycle_id"])["effective"]["user_statement"] == \ + "and then I changed my mind", "the newer direct statement stays in force" + + +def test_a_review_session_that_comes_back_with_different_words_fails_closed(): + """The session key makes a replay a no-op. It must not make a *correction* a + no-op: a user who resumes a review and rewords their reason has said + something new, and keeping the older words while reporting success is the + exact loss this stream exists to stop. `session._append_session_rows` refuses + the same shape rather than choosing which version to keep.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + first = _say(root, ACME, "because of the dividend", source="review", + origin_id="session-abc") + try: + _say(root, ACME, "because of the dividend AND the buyback", + source="review", origin_id="session-abc") + except pr.PositionRationaleError as exc: + assert "already recorded a different rationale" in str(exc) + assert first["event_id"] in str(exc) + else: + raise AssertionError("a changed statement under a used session id must fail closed") + assert len(_rows(root)) == 1, "and nothing is written on the way to refusing" + + +def test_a_forked_subject_is_reported_and_refused_rather_than_healed(): + """Two events superseding one predecessor cannot both be the current reason. + The accepted disposition requires corruption be reported rather than resolved + by file order or timestamp -- so the reader says `forked` and the writer + refuses, instead of appending onto a head that buries one branch for good.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + root_event = _say(root, ACME, "the original reason") + branch = pr.build_event(subject=ACME, act="statement", + user_statement="a second child of the same parent", + stated_at="2026-07-02", capture_source="direct", + state_version="sv-1", supersedes=root_event["event_id"]) + _say(root, ACME, "the first child", stated_at="2026-07-02") + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write(json.dumps(branch, ensure_ascii=False, sort_keys=True) + "\n") + + assert pr.query(root, ACME["cycle_id"])["forked"] == [root_event["event_id"]] + try: + _say(root, ACME, "a reason recorded on top of the fork") + except pr.PositionRationaleError as exc: + assert "ambiguous" in str(exc) + else: + raise AssertionError("appending onto a forked subject must fail closed") + + +def test_an_expected_predecessor_that_moved_fails_closed(): + """A caller that read the head, then acted, must not silently fork the + subject when something landed in between.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + stale = _say(root, ACME, "the reason I read before acting") + _say(root, ACME, "something recorded in between", stated_at="2026-07-02") + try: + pr.append(root, subject=ACME, act="statement", user_statement="mine", + stated_at="2026-07-03", capture_source="direct", + state_version="sv-1", expected_predecessor=stale["event_id"]) + except pr.PositionRationaleError as exc: + assert "expected predecessor" in str(exc) + else: + raise AssertionError("a moved head must fail closed, not fork") + assert len(_rows(root)) == 2 + + +def test_the_frozen_change_vocabulary_is_derived_by_this_reader(): + """`initial | changed | no_change` is not stored. It must still be computed + in exactly one place, or every integration surface rolls its own and the two + disagree -- which is the defect not storing it was meant to end.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "the first reason") + assert pr.query(root, ACME["cycle_id"])["change"] == "initial" + _say(root, ACME, "a different reason", stated_at="2026-07-02") + assert pr.query(root, ACME["cycle_id"])["change"] == "changed" + _confirm(root, ACME, stated_at="2026-07-03") + assert pr.query(root, ACME["cycle_id"])["change"] == "no_change" + assert pr.query(root, "NOSUCH#2026-01-01#1")["change"] is None + + +def test_stated_at_defaults_to_the_local_calendar_date(): + """Frozen by the accepted disposition. The chain, not sub-day precision, + separates two same-day revisions -- so a date is enough, and inventing a + clock time for a statement the user did not timestamp would be a fact + nobody stated.""" + import datetime as _dt + event = pr.build_event(subject=ACME, act="statement", user_statement="a reason", + capture_source="direct", state_version="sv-1") + assert event["stated_at"] == _dt.date.today().isoformat() + backdated = pr.build_event(subject=ACME, act="statement", user_statement="a reason", + stated_at="2026-01-05", capture_source="direct", + state_version="sv-1") + assert backdated["stated_at"] == "2026-01-05", "an explicit date always wins" + + +# ─────────────────── C. confirmations ─────────────────── + +def test_a_confirmation_holds_the_prior_wording_without_restating_it(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + said = _say(root, ACME, "for the dividend") + out = _confirm(root, ACME) + rows = _rows(root) + assert "user_statement" not in rows[1], ( + "copying the wording forward would restate it on a day they did not " + "say it; the pointer is how the record keeps what is in force") + assert rows[1]["supersedes"] == said["event_id"] + query = pr.query(root, ACME["cycle_id"]) + assert query["effective"]["event_id"] == said["event_id"] + assert query["latest"]["event_id"] == out["event_id"], ( + "what is in force and what happened most recently are different " + "questions, and a review needs both") + + +def test_two_confirmations_on_different_days_are_two_events(): + """The frozen contract collided them at the second: two `no_change` rows + naming the same prior statement were byte-identical. A confirmation's whole + informational content is that it happened and when, and #403 makes "how long + since the last statement" an input to whether the user is asked at all.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "for the dividend") + first = _confirm(root, ACME, stated_at="2026-07-02") + again = _confirm(root, ACME, stated_at="2026-07-09") + assert first["event_id"] != again["event_id"] + assert len(_rows(root)) == 3 + same_day = _confirm(root, ACME, stated_at="2026-07-09") + assert same_day["status"] == "no-op", ( + "confirming twice in one day is one confirmation -- absorbed " + "deliberately by head comparison, not lost to a hash collision") + + +def test_a_confirmation_with_nothing_to_confirm_is_refused(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + try: + _confirm(root, ACME) + except pr.PositionRationaleError as exc: + assert "nothing to confirm" in str(exc) + else: + raise AssertionError("a confirmation on an empty chain must fail closed") + + +def test_the_act_decides_whether_words_are_owed_in_both_directions(): + for act, kwargs, fragment in ( + ("statement", {}, "requires the user's own words"), + ("statement", {"user_statement": " "}, "requires the user's own words"), + ("confirmation", {"user_statement": "restated"}, "carries no statement")): + try: + pr.build_event(subject=ACME, act=act, stated_at="2026-07-01", + capture_source="direct", state_version="sv-1", + supersedes="position-rationale-0000", **kwargs) + except pr.PositionRationaleError as exc: + assert fragment in str(exc), (act, kwargs, str(exc)) + else: + raise AssertionError(f"{act} {kwargs} must be refused") + + +# ─────────────────── D. subjects, and a damaged file ─────────────────── + +def test_nothing_is_joined_across_subjects(): + """Including across a sale and rebuy of the same ticker, which is the join + #403 forbids by name: the two cycles are different positions and the reason + for one is not evidence about the other.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "the reason for the first ACME") + _say(root, WIDGET, "an unrelated reason") + _say(root, ACME_2, "the reason for the one I bought back") + for subject, expected in ((ACME, "the reason for the first ACME"), + (WIDGET, "an unrelated reason"), + (ACME_2, "the reason for the one I bought back")): + query = pr.query(root, subject["cycle_id"]) + assert query["total_count"] == 1, subject["cycle_id"] + assert query["effective"]["user_statement"] == expected + assert pr.query(root, "NOSUCH#2026-01-01#1")["total_count"] == 0 + + +def test_a_corrupt_line_costs_one_row_and_is_counted(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "for the dividend") + _say(root, ACME, "for the buyback") + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write("{not json at all\n") + handle.write(json.dumps({"event_id": "x", "act": "statement"}) + "\n") + query = pr.query(root, ACME["cycle_id"]) + assert query["total_count"] == 2, "the readable rows survive" + assert query["unreadable"] == 2, ( + "and the count says so: a silently dropped row would make `total` a " + "lie, which is worse than saying the record is damaged") + + +def test_an_append_onto_a_file_with_no_trailing_newline_writes_two_rows(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "for the dividend") + path = pr._rationale_path(root) + with open(path, encoding="utf-8") as handle: + body = handle.read() + with open(path, "w", encoding="utf-8") as handle: + handle.write(body.rstrip("\n")) # a prior crash, mid-write + _say(root, ACME, "for the buyback") + assert len(_rows(root)) == 2, "the guard writes the missing newline first" + + +# ─────────────────── E. the bounded reader (#450) ─────────────────── + +def test_the_bounded_reader_says_what_it_held_back(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + events = [_say(root, ACME, f"reason {n}", stated_at=f"2026-07-{n:02d}") + for n in range(1, 13)] + query = pr.query(root, ACME["cycle_id"]) + assert query["total_count"] == 12 + assert query["shown_count"] == pr.QUERY_CAP == 8 + assert query["beyond_cap"] == 4 + assert query["items"][0]["event_id"] == events[0]["event_id"], "earliest is kept" + assert query["items"][-1]["event_id"] == events[-1]["event_id"], "and latest" + assert [row["user_statement"] for row in query["items"][1:-1]] == \ + [f"reason {n}" for n in range(6, 12)], "the rest is the most recent" + assert query["span"] == {"first": "2026-07-01", "last": "2026-07-12"}, ( + "the span spans the whole record, not the window: the cap keeps a " + "plan surface readable and must never shrink what it reports about") + assert query["effective"]["user_statement"] == "reason 12" + + +def test_a_short_history_is_returned_whole(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + one = _say(root, ACME, "the only reason") + query = pr.query(root, ACME["cycle_id"]) + assert (query["total_count"], query["shown_count"], query["beyond_cap"]) == (1, 1, 0) + assert query["items"][0]["event_id"] == one["event_id"] + assert query["span"] == {"first": "2026-07-01", "last": "2026-07-01"} + _say(root, ACME, "a second reason", stated_at="2026-07-05") + query = pr.query(root, ACME["cycle_id"]) + assert (query["total_count"], query["shown_count"], query["beyond_cap"]) == (2, 2, 0) + + +def test_an_out_of_order_file_still_reads_in_chain_order(): + """A copied or repaired file can present rows in any order. The predecessor + is what places an event, so the chain -- not the line number -- decides.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "first") + _say(root, ACME, "second") + _say(root, ACME, "third") + rows = _rows(root) + with open(pr._rationale_path(root), "w", encoding="utf-8") as handle: + for row in reversed(rows): + handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") + query = pr.query(root, ACME["cycle_id"]) + assert [row["user_statement"] for row in query["items"]] == \ + ["first", "second", "third"] + assert query["effective"]["user_statement"] == "third" + + +def test_state_version_is_required_because_a_subject_was_resolved_against_a_book(): + try: + pr.build_event(subject=ACME, act="statement", user_statement="a reason", + stated_at="2026-07-01", capture_source="direct", + state_version="") + except pr.PositionRationaleError as exc: + assert "state_version" in str(exc) + else: + raise AssertionError("a rationale with no book version must fail closed") + + +def test_a_subject_is_never_a_ticker_on_its_own(): + for subject, fragment in (({"ticker": "ACME"}, "missing"), + ({**ACME, "sector": "tech"}, "unknown fields")): + try: + pr.build_event(subject=subject, act="statement", user_statement="a reason", + stated_at="2026-07-01", capture_source="direct", + state_version="sv-1") + except pr.PositionRationaleError as exc: + assert fragment in str(exc), (subject, str(exc)) + else: + raise AssertionError(f"{subject} must be refused") + + +def test_recorded_at_is_not_part_of_the_identity(): + """Otherwise no replay is ever a no-op, and the file grows a row per retry.""" + early = pr.build_event(subject=ACME, act="statement", user_statement="a reason", + stated_at="2026-07-01", capture_source="direct", + state_version="sv-1", recorded_at="2026-07-01T09:00:00+00:00") + later = pr.build_event(subject=ACME, act="statement", user_statement="a reason", + stated_at="2026-07-01", capture_source="direct", + state_version="sv-1", recorded_at="2026-07-04T17:30:00+00:00") + assert early["event_id"] == later["event_id"] + assert early["recorded_at"] != later["recorded_at"], "but both are recorded" + + +def test_a_row_the_reader_would_crash_on_is_counted_not_admitted(): + """A row admitted by `load` and then dereferenced by the fold would kill the + reader while `unreadable` still said the file was clean -- worse than a + silent skip, because it inverts the contract instead of weakening it. Every + field a later reader touches without guarding is checked on the way in.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + good = _say(root, ACME, "a real reason") + base = _rows(root)[0] + broken = [ + {**base, "event_id": "no-stated-at", "stated_at": None}, + {**base, "event_id": "unhashable-pointer", "supersedes": ["a", "b"]}, + {**base, "event_id": "not-the-users-voice", "voice": "agent_guess"}, + {**base, "event_id": "bad-source", "capture_source": "invented"}, + {**base, "event_id": "thin-subject", "subject": {"cycle_id": ACME["cycle_id"]}}, + ] + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + for row in broken: + handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") + + query = pr.query(root, ACME["cycle_id"]) + assert query["total_count"] == 1 and query["unreadable"] == len(broken) + assert query["effective"]["event_id"] == good["event_id"] + + +def test_a_damaged_stream_is_readable_but_not_writable(): + """The reader degrades and says by how much; the writer refuses outright. + + A line the loader could not read may be the subject's real head, so + appending against the last *readable* one can root a second branch or make + an older reason current -- and return a success receipt while doing it. The + user's existing words are still there to repair from; a new row written over + a head nobody could see is not.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + good = _say(root, ACME, "a real reason") + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write("{not json at all\n") + + query = pr.query(root, ACME["cycle_id"]) + assert query["total_count"] == 1 and query["unreadable"] == 1, ( + "the reader still returns what it can, and says what it could not") + assert query["effective"]["event_id"] == good["event_id"] + + try: + _say(root, ACME, "a reason written over a head nobody could see") + except pr.PositionRationaleError as exc: + assert "unreadable" in str(exc) and "Repair" in str(exc) + else: + raise AssertionError("appending to a damaged canonical stream must fail closed") + with open(pr._rationale_path(root), encoding="utf-8") as handle: + lines = [line for line in handle if line.strip()] + assert len(lines) == 2, "and nothing is written on the way to refusing" + + +def test_conflicting_content_under_one_event_id_fails_closed(): + """Content addressing makes this unreachable in normal operation, so it means + the file was edited outside the engine. Choosing between the two by file + order would pick a version of the user's words that nothing nominated.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + first = _say(root, ACME, "what I actually said") + forged = dict(_rows(root)[0]) + forged["user_statement"] = "words put in my mouth" + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write(json.dumps(forged, ensure_ascii=False, sort_keys=True) + "\n") + + assert pr.query(root, ACME["cycle_id"])["conflicting_ids"] == [first["event_id"]] + try: + _say(root, ACME, "a later reason", stated_at="2026-07-02") + except pr.PositionRationaleError as exc: + assert "conflicting content" in str(exc) + else: + raise AssertionError("a conflicting duplicate identity must fail closed") + + +def test_a_merged_subject_keeps_every_edge_and_still_reports_the_newest_statement(): + """The relink case a depth-ordered read gets wrong. + + An older chain A1 -> A2 -> A3 plus a newer independent root B1, both on one + subject after a proven relink. Ordering by chain depth puts every A event + after B1, so the user's August statement stops being their latest and an + older March revision is quoted back at them as their current reason -- with + nothing flagged, because a two-root subject is not a fork. The order must + preserve every predecessor edge *and* still end at B1.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + former = "ACME#2026-01-02#1" + old = {**ACME, "cycle_id": former} + a1 = _say(root, old, "A1: the original reason", stated_at="2026-01-10") + a2 = _say(root, old, "A2: revised in February", stated_at="2026-02-10") + a3 = _say(root, old, "A3: revised in March", stated_at="2026-03-10") + b1 = pr.append(root, subject=ACME, act="statement", + user_statement="B1: what I said most recently, in August", + stated_at="2026-08-20", capture_source="direct", + state_version="sv-2") + + query = pr.query(root, ACME["cycle_id"], aliases=[former]) + assert query["total_count"] == 4 + assert [row["user_statement"][:2] for row in query["items"]] == \ + ["A1", "A2", "A3", "B1"], "every predecessor edge intact, newest last" + assert query["latest"]["event_id"] == b1["event_id"] + assert query["effective"]["event_id"] == b1["event_id"], ( + "the August statement is the user's current reason, not the March one") + assert query["change"] == "changed", ( + "the later root is not the first statement about this position; " + "calling it `initial` would tell the next review they never explained it") + assert pr.classify(_rows(root), ACME["cycle_id"], a1["event_id"], + aliases=[former]) == "initial" + for event in (a2, a3): + assert pr.classify(_rows(root), ACME["cycle_id"], event["event_id"], + aliases=[former]) == "changed" + + +def test_a_torn_multibyte_character_costs_one_line_not_the_file(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "管理層換人之後我才進場的") + with open(pr._rationale_path(root), "ab") as handle: + handle.write(b'{"event_id":"torn","user_statement":"\xe7\xae\xa1\xe7\n') + query = pr.query(root, ACME["cycle_id"]) + assert query["total_count"] == 1, "the readable statement survives" + assert query["effective"]["user_statement"] == "管理層換人之後我才進場的" + + +def test_a_small_cap_actually_bounds_the_reader(): + """`x[-0:]` is `x[0:]`, so a cap of 0/1/2 returning the whole chain while + reporting `beyond_cap: 0` would be the bounded surface unbounded, asserting + nothing was held back.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + for n in range(1, 13): + _say(root, ACME, f"reason {n}", stated_at=f"2026-07-{n:02d}") + for cap in (0, 1, 2, 3, 8): + query = pr.query(root, ACME["cycle_id"], cap=cap) + assert query["shown_count"] == len(query["items"]) <= max(2, cap), cap + assert query["beyond_cap"] == 12 - query["shown_count"], cap + + +def test_an_optional_field_supplied_empty_is_refused_not_silently_dropped(): + """Omitting it would make it indistinguishable from absent in the address, + so two different review sessions could hash identically and the second act + be lost as a retry.""" + for key in ("origin_id", "condition_ref"): + try: + pr.build_event(subject=ACME, act="statement", user_statement="a reason", + stated_at="2026-07-01", capture_source="review", + state_version="sv-1", **{key: ""}) + except pr.PositionRationaleError as exc: + assert key in str(exc) + else: + raise AssertionError(f"an empty {key} must be refused") + + +def test_an_independent_branch_cannot_bury_a_newer_statement(): + """Depth places an event inside its own chain and says nothing between two + chains -- which a relink merge legitimately produces. Ordering those by file + order alone lets an older statement be reported as the user's current one.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + old = "ACME#2026-01-15#1" + newer = pr.build_event(subject={**ACME, "cycle_id": old}, act="statement", + user_statement="what I said most recently", + stated_at="2026-08-20", capture_source="direct", + state_version="sv-2") + with open(os.path.join(tmp, "seed.jsonl"), "w") as _: + pass + _say(root, ACME, "an older reason on the other branch", stated_at="2026-07-03") + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write(json.dumps(newer, ensure_ascii=False, sort_keys=True) + "\n") + query = pr.query(root, ACME["cycle_id"], aliases=[old]) + assert query["total_count"] == 2 + assert query["effective"]["user_statement"] == "what I said most recently", ( + "the later statement is the user's current reason even though the " + "other branch was written to the file first") + + +def test_a_relinked_cycle_keeps_the_statements_recorded_under_its_old_id(): + """#450 froze it: the reader follows engine-proven cycle relinks. #563's + later-history upgrade moves a cycle's start, so the id the user's statements + were written under is no longer the id the book calls that position. A reader + matching only the current id would report nothing -- the position would look + remembered and then silently lose its reason, which is the exact failure this + stream exists to prevent. + + Identity stays `thesis.py`'s to prove: the caller passes the relinks, this + reader never guesses at continuity.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + _say(root, ACME, "the reason I gave before the history upgrade") + upgraded = "ACME#2026-01-15#1" # #563 moved the start date + + blind = pr.query(root, upgraded) + assert blind["total_count"] == 0, "without the relink the memory is gone" + + seen = pr.query(root, upgraded, aliases=[ACME["cycle_id"]]) + assert seen["total_count"] == 1 + assert seen["effective"]["user_statement"] == \ + "the reason I gave before the history upgrade" + assert seen["change"] == "initial" + + # And an append continues the same chain rather than starting a new one. + out = pr.append(root, subject={**ACME, "cycle_id": upgraded}, act="statement", + user_statement="and this is why I still hold it", + stated_at="2026-07-02", capture_source="direct", + state_version="sv-2", aliases=[ACME["cycle_id"]]) + assert out["status"] == "appended" + after = pr.query(root, upgraded, aliases=[ACME["cycle_id"]]) + assert after["total_count"] == 2 and after["change"] == "changed" + assert after["items"][-1]["supersedes"] == seen["effective"]["event_id"], ( + "the upgraded cycle's first statement supersedes the pre-upgrade one, " + "rather than opening a second origin on the same position") + + +def test_append_deadlocks_inside_a_held_lock_and_append_locked_does_not(): + """The hazard the `append` / `append_locked` split exists for, pinned rather + than left to a docstring. `session._projection_lock` is a POSIX flock taken + per file descriptor, so re-entering it from the same process blocks forever + -- and the caller that would do this is `review.py` finalize, where the cost + is a hung review rather than an error.""" + probe = ( + "import sys, tempfile, os\n" + f"sys.path.insert(0, {ENGINE!r})\n" + "import session, position_rationale as pr\n" + "S = {'cycle_id':'A#1','ticker':'A','market':'US','currency':'USD'}\n" + "root = tempfile.mkdtemp()\n" + "with session.projection_transaction(root) as locked:\n" + " pr.%s(locked, subject=S, act='statement', user_statement='x',\n" + " stated_at='2026-07-01', capture_source='direct', state_version='v')\n" + "print('done')\n") + import subprocess + try: + subprocess.run([sys.executable, "-c", probe % "append"], + capture_output=True, timeout=6) + except subprocess.TimeoutExpired: + pass + else: + raise AssertionError( + "append() inside a held projection lock must block -- if this stopped " + "being true the split is no longer load-bearing and should be simplified") + done = subprocess.run([sys.executable, "-c", probe % "append_locked"], + capture_output=True, timeout=60, text=True) + assert done.returncode == 0 and "done" in done.stdout, done.stderr + + +def test_the_shared_fixture_reads_the_way_every_consumer_must_read_it(): + """#450's shared fixture, exercised through the same public reader the + integration slice will use. It exists so this suite, the integration slice + and #450's later multi-source reader assert over one history instead of each + inventing its own -- two readers with two fixtures is how they end up + disagreeing about what the record says.""" + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "coach") + seeded = rationale_fixture.seed(root) + want = seeded["expect"] + + acme = pr.query(root, seeded["acme"]["cycle_id"]) + assert acme["total_count"] == want["acme_total"] + assert acme["effective"]["user_statement"] == want["acme_effective"] + assert acme["change"] == want["acme_change"], ( + "the latest act is a confirmation, so nothing changed -- while the " + "wording in force is still the statement it confirms") + assert acme["latest"]["act"] == "confirmation" + + widget = pr.query(root, seeded["widget"]["cycle_id"]) + assert widget["change"] == want["widget_change"] + assert widget["effective"]["user_statement"] == want["widget_effective"] + + relinked = seeded["relinked"] + blind = pr.query(root, relinked["cycle_id"]) + assert blind["total_count"] == want["relinked_total_without_alias"], ( + "without the engine-proven relink the pre-upgrade statement is " + "invisible -- the failure the fixture carries this case to catch") + seen = pr.query(root, relinked["cycle_id"], aliases=relinked["aliases"]) + assert seen["total_count"] == want["relinked_total"] + assert seen["effective"]["user_statement"] == want["relinked_effective"] + + empty = pr.query(root, seeded["orphan"]["cycle_id"]) + assert empty["total_count"] == want["orphan_total"] + assert empty["effective"] is None and empty["change"] is None, ( + "a position with no recorded reason returns an empty history rather " + "than raising or inventing one") + + +def _main(): + tests = [(name, fn) for name, fn in sorted(globals().items()) + if name.startswith("test_") and callable(fn)] + passed = failed = 0 + for name, fn in tests: + try: + fn() + passed += 1 + print(f"PASS {name}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {name}: {exc}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f"ERROR {name}: {type(exc).__name__}: {exc}") + print(f"\n{passed} passed, {failed} failed") + return failed + + +if __name__ == "__main__": + sys.exit(1 if _main() else 0) diff --git a/tests/test_preview_gate.py b/tests/test_preview_gate.py index eda682a7..20c81c52 100644 --- a/tests/test_preview_gate.py +++ b/tests/test_preview_gate.py @@ -68,7 +68,8 @@ def _choice(question): "due_revisit": "skip", "condition_crossing": "skip", "condition_basis": "skip", - }.get(question["kind"], "deliberate_plan") + }.get(question["kind"], "skip" if question["kind"] == "rationale_refresh" + else "deliberate_plan") def _thesis_updates(plan, maturity="inferred"): diff --git a/tests/test_rationale_review.py b/tests/test_rationale_review.py new file mode 100644 index 00000000..51453e5f --- /dev/null +++ b/tests/test_rationale_review.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""The rationale integration slice (#403): direct entry → next review. No pytest. + +What this file settles — and the first item is the acceptance condition the +whole slice exists for: + + A. A statement recorded outside a review is quoted back by the next review, + with the user's own words and the date they said them, instead of the + review asking the same fact from zero. Until this passes, a row in + `position_rationales.jsonl` is a writer without a reader (#429) and nothing + may call it memory. + B. A reason recorded days ago is not re-asked. The value of #403 is a series + over quarters, and a question that arrives too often trains the user to + answer it without thinking. + C. Answering `same` records a confirmation, `changed` records the new words + while keeping the old, and `skip` records nothing at all. + D. A rationale that cannot be recorded does not fail the review — the owner's + 2026-07-31 ruling that a rationale and its neighbours are independent + outcomes with separate receipts — and is never reported as recorded. + E. A forked subject is not asked about, because there the reader's order is + not the record's and the question could quote a statement the user did not + most recently make. + +Division of labour: `tests/test_position_rationale.py` owns the stream, its fold +and the bounded reader. This file owns the two entrances and the plan surface +between them. The light-capture migration is a later slice and is not here. + +Run: + python3 tests/test_rationale_review.py +""" +import json +import os +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +SKILL = os.path.join(ROOT, "skills", "fomo-kernel") +ENGINE = os.path.join(SKILL, "engine") +CLI = os.path.join(ENGINE, "review.py") +MOCK = os.path.join(SKILL, "mock", "sample_pyramid.csv") + +sys.path.insert(0, HERE) +import offline_posture # noqa: E402 +offline_posture.apply() +sys.path.insert(0, ENGINE) +import position_rationale as pr # noqa: E402 +import review as review_engine # noqa: E402 + + +def _run(*argv, root=None): + out = subprocess.run([sys.executable, CLI, *argv, "--root", root], + capture_output=True, text=True, timeout=300) + try: + return out.returncode, json.loads(out.stdout or "{}") + except ValueError: + raise AssertionError(f"non-JSON from {argv[0]}: {out.stdout[-400:]}\n{out.stderr[-400:]}") + + +def _prepared(root): + """A fresh Review Plan, discarding any pending one first so a second call in + the same test reflects state recorded in between.""" + pending = os.path.join(root, ".pending") + if os.path.isdir(pending): + subprocess.run([sys.executable, "-c", + f"import shutil;shutil.rmtree({pending!r})"], check=True) + code, payload = _run("prepare", MOCK, "--language", "en", root=root) + assert code == 0, payload + return payload["review_plan"] + + +def _say(root, ticker, statement, *, stated_at=None): + argv = ["record-rationale", "--ticker", ticker, "--statement", statement] + if stated_at: + argv += ["--stated-at", stated_at] + code, payload = _run(*argv, root=root) + assert code == 0, payload + return payload + + +def _refresh_question(plan): + for row in plan.get("question_queue") or []: + if row.get("kind") == "rationale_refresh": + return row + return None + + +# ─────────────── A. the acceptance condition ─────────────── + +def test_a_statement_recorded_outside_a_review_is_quoted_back_by_the_next_one(): + """#403's stop boundary, and the only thing that turns a stored row into + memory. The user says why they hold something, on their own, with no review + running. The next review opens by putting it back to them -- their wording, + their date -- so the question is confirm-or-correct rather than reconstruct + from memory.""" + with tempfile.TemporaryDirectory() as root: + _prepared(root) + said = "會員費續約率還在爬,我當定存放" + recorded = _say(root, "COST", said, stated_at="2026-01-10") + assert recorded["status"] == "appended" + + plan = _prepared(root) + state = (plan["state_snapshot"]["position_rationales"] or []) + entry = next(row for row in state if row["ticker"] == "COST") + assert entry["statement"] == said, "verbatim, into the plan" + assert entry["stated_at"] == "2026-01-10" + assert entry["days_since"] > 0 + + question = _refresh_question(plan) + assert question is not None, ( + "a reason this old earns a candidate slot; without a question the " + "statement reaches the plan and still nothing asks about it") + assert said in question["question"], ( + "the stem quotes the user's own words rather than paraphrasing them") + assert "2026-01-10" in question["question"], ( + "and dates the quote, so it reads as a record and not a present-tense claim") + assert question["prior_rationale"]["event_id"] == recorded["event_id"], ( + "the question names the exact event the answer will supersede") + assert question["ticker"] == "COST" + + +def test_the_plan_says_so_even_when_nothing_was_ever_recorded(): + """The key is present and empty rather than absent, so "you have told me + nothing about why you hold these" is a claim the plan makes rather than a + silence a reader has to interpret.""" + with tempfile.TemporaryDirectory() as root: + plan = _prepared(root) + state = plan["state_snapshot"] + assert state["position_rationales"] == [] + summary = state["position_rationales_summary"] + assert summary["positions_with_a_recorded_reason"] == 0 + assert summary["positions_without_one"] >= 1 + assert _refresh_question(plan) is None, "and nothing is asked about nothing" + + +# ─────────────── B. not asking too often ─────────────── + +def test_a_reason_given_days_ago_is_not_re_asked(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "just told you this") # stated_at defaults to today + plan = _prepared(root) + entry = next(row for row in plan["state_snapshot"]["position_rationales"] + if row["ticker"] == "COST") + assert entry["days_since"] == 0 + assert _refresh_question(plan) is None, ( + "re-asking now would learn nothing and teach the user to answer " + "without thinking, which is worse than not asking") + + +def test_at_most_one_refresh_question_and_the_stalest_wins(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the older reason", stated_at="2026-01-10") + _say(root, "UNH", "the less old reason", stated_at="2026-03-20") + plan = _prepared(root) + asked = [row for row in plan["question_queue"] or [] + if row["kind"] == "rationale_refresh"] + assert len(asked) <= 1, "one slot, competing with everything else this week" + if asked: + assert asked[0]["ticker"] == "COST", "the stalest reason is the one worth asking about" + ordered = [row["ticker"] for row in plan["state_snapshot"]["position_rationales"]] + assert ordered == ["COST", "UNH"], "and the surface itself is oldest-first" + + +# ─────────────── C. what an answer does ─────────────── + +def _answered_plan(root, ticker, choice, note=None): + """Drive the review-lane consumer directly against a real prepared plan. + + The consumer is the unit under test here; a full prepare/preview/finalize + walk is `tests/test_review_v2.py`'s job and would not exercise anything more + of this path. + """ + plan = _prepared(root) + question = _refresh_question(plan) + assert question is not None and question["ticker"] == ticker, question + answer = {"question_id": question["id"], "choice": choice} + if note is not None: + answer["note"] = note + return review_engine._record_rationale_answers( + plan, {"answers": [answer]}, {question["id"]: answer}) + + +def test_answering_same_records_a_confirmation_without_restating_the_words(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + first = _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + written = _answered_plan(root, "COST", "same") + assert len(written) == 1 and written[0]["status"] == "appended" + + view = pr.query(root, written[0]["cycle_id"]) + assert view["change"] == "no_change" + assert view["latest"]["act"] == "confirmation" + assert "user_statement" not in view["latest"], ( + "a confirmation says the wording still holds; copying it forward " + "would restate it on a day the user did not say it") + assert view["effective"]["event_id"] == first["event_id"] + assert view["latest"]["capture_source"] == "review" + + +def test_answering_changed_records_the_new_words_and_keeps_the_old(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + written = _answered_plan(root, "COST", "changed", note="改成看它電商營收佔比") + assert written[0]["status"] == "appended" + + view = pr.query(root, written[0]["cycle_id"]) + assert view["total_count"] == 2, "append, never rewrite" + assert view["change"] == "changed" + assert view["effective"]["user_statement"] == "改成看它電商營收佔比" + assert view["items"][0]["user_statement"] == "the reason I gave in January", ( + "the superseded wording stays exactly as they wrote it") + + +def test_answering_changed_without_words_is_refused(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + try: + _answered_plan(root, "COST", "changed", note=" ") + except review_engine.ReviewError as exc: + assert "own words" in str(exc) + else: + raise AssertionError("recording that it changed without saying to what stores nothing") + + +def test_answering_skip_records_nothing(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + first = _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + assert _answered_plan(root, "COST", "skip") == [] + view = pr.query(root, first["cycle_id"]) + assert view["total_count"] == 1, ( + "the honest shape of a question posed and not answered: nothing " + "pretends the user weighed in") + + +def test_a_choice_the_question_did_not_offer_is_refused(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + try: + _answered_plan(root, "COST", "invented") + except review_engine.ReviewError as exc: + assert "not one of the choices" in str(exc) + else: + raise AssertionError("an unoffered choice must fail closed") + + +def test_the_same_session_answering_twice_is_a_no_op(): + """Finalize is idempotent and a documented-safe retry, so the review lane + must not append a second event for the same session and subject.""" + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + plan = _prepared(root) + question = _refresh_question(plan) + answer = {"question_id": question["id"], "choice": "same"} + amap = {question["id"]: answer} + first = review_engine._record_rationale_answers(plan, {"answers": [answer]}, amap) + again = review_engine._record_rationale_answers(plan, {"answers": [answer]}, amap) + assert first[0]["status"] == "appended" and again[0]["status"] == "no-op" + assert again[0]["event_id"] == first[0]["event_id"] + assert pr.query(root, first[0]["cycle_id"])["total_count"] == 2 + + +# ─────────────── D/E. what fails, and how loudly ─────────────── + +def test_a_rationale_that_cannot_be_recorded_does_not_fail_the_review(): + """The mechanism `tests/test_split_basis.py`'s `_ROUTES_NOT_DRIVEN` entry for + `finalize` names. A card the user has already been shown is not discarded + because a statement could not be attached to a position -- the owner's + 2026-07-31 ruling on independent outcomes with separate receipts. The + converse matters just as much: a refused rationale is never reported as + recorded.""" + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the reason I gave in January", stated_at="2026-01-10") + plan = _prepared(root) + question = _refresh_question(plan) + # The head moved after the question was asked: the user answered about a + # statement that is no longer current, and guessing which they meant is + # the one thing this stream must never do. + _say(root, "COST", "something I said in between", stated_at="2026-07-01") + answer = {"question_id": question["id"], "choice": "same"} + try: + review_engine._record_rationale_answers( + plan, {"answers": [answer]}, {question["id"]: answer}) + except pr.PositionRationaleError as exc: + assert "expected predecessor" in str(exc) + else: + raise AssertionError("an answer against a moved head must fail closed") + # And cmd_finalize catches exactly this pair, so the card still commits + # and the failure is surfaced rather than swallowed. + import inspect + source = inspect.getsource(review_engine.cmd_finalize) + assert "_record_rationale_answers" in source + assert "position_rationale.PositionRationaleError" in source, ( + "the refusal above must be caught at finalize, or a rationale that " + "cannot be attached takes the user's whole review down with it") + assert '"rationale_error": rationale_error' in source, ( + "and it must be reported: a refused rationale is never silently absent") + + +def test_a_forked_subject_is_not_asked_about(): + with tempfile.TemporaryDirectory() as root: + _prepared(root) + first = _say(root, "COST", "the original reason", stated_at="2026-01-10") + branch = pr.build_event( + subject={"cycle_id": first["cycle_id"], "ticker": "COST", + "market": "US", "currency": "USD"}, + act="statement", user_statement="a second child of the same parent", + stated_at="2026-02-01", capture_source="direct", state_version="sv-x", + supersedes=first["event_id"]) + _say(root, "COST", "the first child", stated_at="2026-02-01") + with open(pr._rationale_path(root), "a", encoding="utf-8") as handle: + handle.write(json.dumps(branch, ensure_ascii=False, sort_keys=True) + "\n") + + plan = _prepared(root) + entry = next(row for row in plan["state_snapshot"]["position_rationales"] + if row["ticker"] == "COST") + assert entry["forked"], "the plan says the subject is forked" + assert _refresh_question(plan) is None, ( + "and asks nothing about it: the reader's order is not the record's " + "there, so the stem could quote a statement the user did not most " + "recently make") + + +def test_a_relinked_position_does_not_silently_lose_its_reason(): + """The alias mechanism, exercised through the plan rather than the module. + + `_thesis_event_history` returns `(thesis_rows, decision_rows)`. Passing that + tuple whole made `_cycle_aliases` filter both lists away as non-dicts and + return `{}` — so after a proven relink the statement stayed on disk while the + plan asserted the user had never given a reason. Silent, and exactly the + "remembered, then gone" failure the alias parameter exists to prevent.""" + with tempfile.TemporaryDirectory() as root: + _prepared(root) + _say(root, "COST", "the reason I gave before the upgrade", stated_at="2026-01-10") + plan = _prepared(root) + entry = next(row for row in plan["state_snapshot"]["position_rationales"] + if row["ticker"] == "COST") + cycle_id = entry["cycle_id"] + + # A relink row of the shape `thesis.build_snapshot_cycle_relinks` emits, + # moving this position's cycle start the way #563 does. + moved = "COST#2023-06-01#1" + import thesis as thesis_engine + relink = {"event": "thesis_cycle_relink", "schema_version": 2, "ticker": "COST", + "cycle_id": moved, "status": "open", "position_status": "open", + "origin": "snapshot", + "cycle_provenance": {"kind": "snapshot_cycle_relink", + "from_cycle_id": cycle_id, + "basis": "unique_open_ticker"}} + relink["event_id"] = thesis_engine.stable_event_id("thesis-cycle-relink", relink) + with open(os.path.join(root, "theses.jsonl"), "a", encoding="utf-8") as handle: + handle.write(json.dumps(relink, ensure_ascii=False, sort_keys=True) + "\n") + + # Through the production helper every call site uses, not a local + # re-derivation -- the bug was in the composition, so testing anything + # else would have stayed green through it. + aliases = review_engine._alias_map(root) + assert aliases.get(moved) == [cycle_id], ( + "the fold must see the relink row; passing the history tuple whole " + "filtered every row away and returned {}") + assert pr.query(root, moved, aliases=aliases.get(moved, []))["total_count"] == 1, ( + "and the statement is found under the position's new id") + + +def _main(): + tests = [(name, fn) for name, fn in sorted(globals().items()) + if name.startswith("test_") and callable(fn)] + passed = failed = 0 + for name, fn in tests: + try: + fn() + passed += 1 + print(f"PASS {name}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {name}: {exc}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f"ERROR {name}: {type(exc).__name__}: {exc}") + print(f"\n{passed} passed, {failed} failed") + return failed + + +if __name__ == "__main__": + sys.exit(1 if _main() else 0) diff --git a/tests/test_review_v2.py b/tests/test_review_v2.py index 6cb1e586..91307d66 100644 --- a/tests/test_review_v2.py +++ b/tests/test_review_v2.py @@ -8624,7 +8624,12 @@ def test_all_json_schemas_parse(): # trade-evaluation.schema.json the way `context` and `agent_case` # above are: a row carrying it would be a derived duplicate of # fields that row already freezes. - "evaluation-challenge.schema.json"} + "evaluation-challenge.schema.json", + # #403 Front B: the canonical position-rationale event. Standalone + # and deliberately not $ref-ed from anywhere yet -- it describes a + # durable row in its own stream, not an agent-facing envelope, and + # this slice adds no CLI and no question that could carry one. + "position-rationale.schema.json"} assert names == {p.name for p in SCHEMAS.glob("*.json")} for path in SCHEMAS.glob("*.json"): assert json.loads(path.read_text(encoding="utf-8"))["$schema"].endswith("2020-12/schema") diff --git a/tests/test_split_basis.py b/tests/test_split_basis.py index bcf3690e..06cce3e7 100644 --- a/tests/test_split_basis.py +++ b/tests/test_split_basis.py @@ -525,7 +525,7 @@ def test_the_split_map_changes_the_books_own_identity(): }) # Routes driven below. Each name is a `cmd_` in review.py. -_DRIVEN_ROUTES = ("consider", "prepare", "refresh", "positions") +_DRIVEN_ROUTES = ("consider", "prepare", "refresh", "positions", "record-rationale") # A route may sit here only with a reason naming who owns it instead. _ROUTES_NOT_DRIVEN = { @@ -539,6 +539,18 @@ def test_the_split_map_changes_the_books_own_identity(): # call-graph argument: it is pinned by # tests/test_review_v2.py::test_add_cash_refuses_when_more_than_the_anchor_moved. "add-cash": "delegates to prepare and refuses any engine_state drift outside cash", + # `finalize` (#403) reaches the book through exactly one call, + # `_resolve_rationale_subject`, and only to answer the question + # `record-rationale` — driven above — asks of it: is this ticker an open + # position, and which cycle. It reads no share count, no cost and no weight, + # so no number on the card or in the bundle moves with the split basis. And + # the one thing that could go wrong there cannot reach the review: a book + # that failed to resolve the subject is receipted as `rationale_error` + # beside a card that still commits, per the owner's 2026-07-31 ruling that + # a rationale and its neighbours are independent outcomes with separate + # receipts. This is a mechanism, not a call-graph argument: it is pinned by + # tests/test_rationale_review.py::test_a_rationale_that_cannot_be_recorded_does_not_fail_the_review. + "finalize": "reads the book only to resolve a rationale subject; failure is receipted, not fatal", } @@ -694,6 +706,28 @@ def test_positions_answers_on_the_split_adjusted_book(): "a split is a zero-dollar event", rows["NVDA"]) +def test_record_rationale_finds_the_position_on_a_split_crossing_book(): + """`record-rationale` (#403) reads the book to answer one question -- is this + a position you hold, and which cycle is it -- and that answer is what decides + whether the user's words are recorded at all. + + The observable is therefore the refusal, not a number. Split-blind, the + share count is wrong in a way that can read as a position no longer held, + and the route then refuses to record a reason for something the user does + own: their words are turned away on the strength of an arithmetic error. + That is worse than a wrong figure, because nothing about the message tells + the user the book was the problem.""" + with tempfile.TemporaryDirectory() as tmp: + _crossing_root(tmp) + code, payload = _route(tmp, "record-rationale", "--ticker", "NVDA", + "--statement", "still holding it through the split") + assert code == 0, payload + assert payload["status"] == "appended", payload + assert payload["ticker"] == "NVDA" + assert payload["effective_statement"] == "still holding it through the split" + assert payload["cycle_id"], "the subject resolves to a real cycle, not a guess" + + def test_a_review_can_start_at_all_on_a_split_crossing_book(): """The compounding failure, and the reason a route-level check is worth having. `prepare` reads the same book through `plan_refresh`, so a