Skip to content

W4 /price Route Cutover — Breaking Change Spec

Date: 2026-05-11 Status: Draft (W4 implementation reference) Owner: liang Sibling docs:

2026-05-08-tushare-integration-design.md §9Target response schema (the canonical "what")
2026-05-10-lifespan-integration-spec.mdT2.7 lifespan wiring (the prerequisite)
2026-05-08-deployment-toolset-rollout.md §7 W4Plan placement (the "when")

1. Background

The current /price route emits the v0.2.0 envelope (≈5 fields):

json
{
  "value": 47.52,
  "metric": "close",
  "code": "600519.SH",
  "as_of": "2026-05-07",
  "cite": {
    "kind": "tool",
    "source": "tushare",
    "table": "daily",
    "fetched_at": "2026-05-07T07:30:00+00:00",
    "tool_call_id": "tc_abc123",
    "served_by": "tushare"
  }
}

02-tushare-integration-design.md §9.2 defined a richer target shape (13 fields + units), aligned with the W2 architecture:

json
{
  "ts_code": "600519.SH",
  "trade_date": "2026-05-07",
  "close_raw": 47.52,
  "close_qfq": 46.12,
  "close_hfq": 49.80,
  "open_raw": 45.30,
  "high_raw": 47.90,
  "low_raw": 45.10,
  "volume": 1283000,
  "amount": 5917430,
  "adj_factor": 1.234,
  "adj_factor_latest": 1.272,
  "as_of": "2026-05-07T07:30:00Z",
  "freshness_seconds": 480,
  "source": "warehouse",
  "source_chain": ["warehouse"],
  "market_state": "closed_final",
  "units": {
    "price": "CNY",
    "volume": "lots_100shares",
    "amount": "thousand_CNY",
    "share_count": "ten_thousand_shares",
    "market_cap": "ten_thousand_CNY"
  },
  "cite": {
    "kind": "tool",
    "tool_call_id": "tc_abc123",
    "fetched_at": "2026-05-07T07:30:00Z",
    "source_chain": ["warehouse"]
  }
}

The new shape captures everything the W2 Quote dataclass already holds, plus explicit units (so consumers can never repeat incident #4's unit confusion). The breaking change is unavoidable: the names and types of multiple fields change.

This spec lays out the rollout: server change, client adaptation, Hermes profile migration, rollback.


2. Goals

  1. Single cutover moment. Server new schema + clients adapted + Hermes profile reinstalled, all within one hour.
  2. Zero data loss. Every value the old envelope held (close, metric, code, as_of) is preserved verbatim or 1:1-mappable to the new schema.
  3. Fast rollback. If post-cutover issues surface, server revert restores old envelope within minutes (Hermes profile gracefully handles both during the window).
  4. No mid-state. Either fully old or fully new. No partial emission of mixed-shape envelopes (defends against the same "agent parses field that isn't there" class of bugs that produced incident #5).

3. Non-goals

  • Long compatibility window (>1 day of dual emission). Spec §9 was explicit that this is a single-cutover breaking change.
  • Multiple API versions (/v1/price, /v2/price). v0.3.0 is still a single-product, single-customer stage; route versioning adds more surface area than it solves.
  • Backward-compatible field synonyms. value was always ambiguous (qfq? raw? hfq?). Renaming to close_raw is the point.
  • Migrating non-skill consumers (fetch_price.py is the only one).
  • Server-side feature flag (toggle old vs new at runtime). Adds config surface; rollback via revert is cleaner.

4. Field mapping (old → new)

Old fieldNew fieldNote
valueclose_qfq (when adj factors available) or close_rawThe old "value" was unmarked w.r.t. adjustment; new schema disambiguates
metricdroppedImplied by which key holds the number; new shape always returns multiple numbers
codets_codeVerbatim rename
as_of (YYYY-MM-DD)trade_date (YYYY-MM-DD) AND as_of (UTC ISO datetime)Old as_of was the trade date; new schema separates "what date is the data" from "when was it fetched"
cite.sourcesource (top-level)Promoted
cite.tabledroppedProvider-internal detail; not load-bearing for consumers
cite.served_bysource (top-level)Same data; deduplicate
cite.fetched_atas_of (top-level)Verbatim, promoted
cite.tool_call_idcite.tool_call_idVerbatim
cite.kindcite.kind (still always "tool")Verbatim
(new)source_chain (list[str])Composer's ordered list of attempted providers (audit context)
(new)close_raw / open_raw / high_raw / low_rawOHLC unadjusted; close was implicit before
(new)close_hfq后复权 close (Quote property)
(new)volume / amountTushare ships these but old envelope dropped them
(new)adj_factor / adj_factor_latestRequired for qfq/hfq math
(new)freshness_secondsHow stale the data is right now
(new)market_stateOPEN / CLOSING / CLOSED_FINAL / PRE_OPEN
(new)unitsPer-field unit string; consumers cannot mistake 万元 vs 千元

close_qfq defaults to close_raw when adj_factor and adj_factor_latest are both available (i.e., the property computes); otherwise null.


5. Server-side change

5.1 Route refactor

src/service/routes/price.py replaces the current DailyCache-direct read with a delegation to app.state.composer.get_quote(...) (W2.7 lifespan wires this up — see 2026-05-10-lifespan-integration-spec.md §4).

Skeleton (target):

python
from fastapi import APIRouter, Depends, HTTPException, Request, status

router = APIRouter()


def get_composer(request: Request) -> QuoteComposer:
    return request.app.state.composer


@router.get("/price")
def get_price(
    code: str,
    composer: Annotated[QuoteComposer, Depends(get_composer)],
    _principal: Annotated[Principal, Depends(require_bearer)],
    trade_date: str | None = None,
) -> dict[str, Any]:
    try:
        quote = composer.get_quote(code, trade_date=trade_date)
    except ProviderUnavailable as exc:
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=f"{exc}",
        ) from exc
    except (CapabilityNotSupported, CanonicalUnitViolation) as exc:
        # Bug paths — surface 500 so on-call notices.
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"{type(exc).__name__}: {exc}",
        ) from exc

    return _envelope_from_quote(quote, composer.last_audit_id())


def _envelope_from_quote(quote: Quote, audit_id: int) -> dict[str, Any]:
    return {
        "ts_code": quote.ts_code,
        "trade_date": quote.trade_date,
        "close_raw": quote.close_raw,
        "close_qfq": quote.close_qfq,
        "close_hfq": quote.close_hfq,
        "open_raw": quote.open_raw,
        "high_raw": quote.high_raw,
        "low_raw": quote.low_raw,
        "volume": quote.volume,
        "amount": quote.amount,
        "adj_factor": quote.adj_factor,
        "adj_factor_latest": quote.adj_factor_latest,
        "as_of": quote.as_of.isoformat(),
        "freshness_seconds": quote.freshness_seconds,
        "source": quote.source,
        "source_chain": [quote.source],  # Composer would supply full chain via Quote ext
        "market_state": "...",  # from composer's market_state.resolve()
        "units": _UNITS,
        "cite": {
            "kind": "tool",
            "tool_call_id": f"tc_{secrets.token_hex(6)}",
            "fetched_at": quote.as_of.isoformat(),
            "source_chain": [quote.source],
        },
    }

5.2 What gets deleted server-side

After the route refactor lands and is verified, T2.8 (Phase D of the lifespan integration spec) removes:

  • src/service/cache.py (DailyCache, FundamentalsCache) — fully superseded by QuoteCache
  • core/core/data/_market.py — superseded by MarketStateResolver
  • The _envelope_from_cache() helper currently in routes/price.py
  • The direct core.data.schemas.get_price call in the route (the function itself stays for direct-mode skill scripts)

These deletions are part of cutover; not a separate PR. Trying to do them later orphans dead code paths that hide bugs.

5.3 What stays

  • core.data.schemas.get_price() keeps its signature for direct-mode skill clients (DirectClient). The skill _client.py still has the dual-mode abstraction; direct mode users (rare; primarily test fixtures) don't get the new shape.

6. Client-side change

6.1 fetch_price.py refactor

skill/stock-research/scripts/fetch_price.py currently emits the envelope verbatim from client.get_price(...). After cutover, the envelope shape changes; the script must:

  1. Read the new top-level fields (close_qfq is the primary "current price", with close_raw as fallback when factors are unavailable)
  2. Decide which value the agent gets in value (back-compat for prompt templates that still read value)
  3. Surface freshness_seconds and market_state to the agent so it can reason about staleness
  4. Preserve the cite block (downstream cite validation depends on it)

Minimum-effort adaptation: emit BOTH the new envelope as the top-level output AND a back-compat value field that points to close_qfq (or close_raw if qfq is null):

python
# fetch_price.py — after-cutover form
def main(argv: list[str]) -> int:
    ...
    envelope = client.get_price(...)  # new shape
    envelope["value"] = (
        envelope.get("close_qfq") or envelope.get("close_raw")
    )  # back-compat: agent prompts that read `value` still work
    envelope["metric"] = "close_qfq" if envelope.get("close_qfq") else "close_raw"
    emit(envelope)
    return 0

This dual emission lets the agent prompt templates that currently quote value and metric continue to work without rewriting all prompts in the Hermes profile.

6.2 References / templates / SOUL.md

skill/stock-research/:

  • SKILL.md §1: mentions "Output is a JSON envelope with value, as_of, metric, and a cite block." → update to mention new shape + back-compat value/metric fields.
  • references/tool-usage.md: per-tool envelope shape. Major rewrite.
  • references/price-freshness.md: served_by matrix → expanded with source_chain and freshness_seconds.
  • references/session-context.md: example envelopes embedded in prose. Update.
  • templates/operation-strategy.md: if any field references inline, update.

6.3 Hermes profile installation

scripts/install-skill.sh already exists. After cutover deployment:

bash
# Operator runs:
TWILIGHT_HOME=~/.hermes/profiles/stock-research-agent bash scripts/install-skill.sh
hermes restart  # picks up new SKILL.md + references

Then in the live Hermes session, the next fetch_price.py call returns the new shape. The agent's existing prompts use value (still populated) and quote cite (still present), so no prompt changes are strictly required for basic usage.


7. Cutover playbook

A single 60-minute window:

T+00:00  Pre-flight check
          - Confirm latest main (W2 PRs all merged + T2.7 lifespan)
          - ssh tvps 'docker compose pull && docker compose ps' (current)
          - Snapshot DB:  cp warehouse.duckdb warehouse.duckdb.pre-w4

T+00:05  Server deploy
          - git tag v0.3.0-w4-cutover; git push origin v0.3.0-w4-cutover
          - On tvps:  docker compose pull && docker compose up -d
          - Wait for /healthz green; check /price returns NEW shape:
              curl -s -H "Authorization: Bearer $TOK" \
                  "https://api.fsagent.cc/price?code=600519.SH" | jq '.close_raw'
            (should return a number, not 'null')

T+00:15  Hermes profile reinstall
          - From repo root:  bash scripts/install-skill.sh
          - hermes restart (in user's main shell)

T+00:25  Smoke test the agent
          - Fetch price for 600519.SH: confirm new fields visible in
            agent log
          - Fetch price for a historical date: verify close_qfq math
          - Trigger a deliberate cache scenario (latest + 60s later):
            confirm freshness_seconds reflects elapsed time

T+00:40  Decision point
          - All checks green → continue
          - Any check red → rollback (see §8)

T+00:55  Update status doc
          - docs/status/index.md: W4 done, link to this spec
          - git push to docs branch

8. Rollback strategy

If post-cutover (within 24h) issues surface that don't have a forward-fix in <1 hour:

ROLLBACK PROCEDURE
1. Server: revert to previous image
     ssh tvps 'docker compose pull <previous-tag> && docker compose up -d'
2. fetch_price.py: revert SKILL files
     git checkout v0.2.0 -- skill/stock-research/
     bash scripts/install-skill.sh
     hermes restart
3. DB: restore warehouse.duckdb if any schema regressions
     ssh tvps 'cp ~/twilight/data/warehouse.duckdb.pre-w4 ~/twilight/data/warehouse.duckdb'

warehouse.duckdb.pre-w4 is the snapshot from T+00:00. Cutover does not migrate any data; the snapshot is for "schema accidentally modified" defense.

Server rollback restores old envelope shape. fetch_price.py rollback restores old emission. Both should complete within 10 minutes; operator should plan for 30-min total rollback window.


9. Test plan

9.1 Pre-cutover (in CI)

  • core/tests/integration/test_service_price_route.py (new):
    • Hits /price?code=... against TestClient
    • Verifies new shape: all 17 top-level fields + 5 units + 4 cite
    • Verifies close_qfq math when factors present
    • Verifies close_qfq is None when factors absent
    • Verifies 502 on ProviderUnavailable
    • Verifies 500 on CapabilityNotSupported / CanonicalUnitViolation
  • core/tests/integration/test_skill_scripts.py:
    • Updated test_service_mode_calls_backend to mock new shape; verifies fetch_price.py emits value (back-compat) + new fields

9.2 Post-cutover (live)

  • curl /price?code=600519.SH returns new shape
  • curl /price?code=600519.SH&trade_date=2024-01-01 returns historical with non-null qfq
  • curl /price?code=NONEXISTENT.SH returns 502 (not 500)
  • fetch_price.py 600519 from Hermes profile emits agent-readable output containing new freshness_seconds and market_state
  • Hermes agent quotes price in chat: works without prompt rewrite

9.3 Performance regression check

  • Pre-cutover /price p95 latency baseline (from existing monitoring); post-cutover p95 should be within 30% (Composer adds overhead from market state lookup + audit log writes)

10. Dependencies

W4 cutover requires these merged before it can land:

  • T2.7 lifespan injection (depends on user PR #38 + all in-flight W2 alt PRs)
  • /price route's current cache (service.cache.DailyCache) — being retired in cutover; depends on QuoteCache (PR #39) being merged

W4 cutover uses the following components already merged or in-flight:

ComponentStatus
QuoteComposerMerged (#34)
TushareDailyProviderMerged (#32)
AkshareSpotProviderMerged (#33)
MarketStateResolverMerged (#30)
WarehousePoolMerged (#26)
QuoteCacheIn-flight (#39)
AuditLogIn-flight (#41)
Composer ↔ AuditLogIn-flight (#42)
Freshness syncIn-flight (#43)
AuditLog webhookIn-flight (#46)
ServiceClient retryIn-flight (#48)
User PR #38 (W3 ingest)In-flight

11. Open questions

  1. Should close_qfq is None short-circuit to close_raw in value back-compat? Spec §6.1 above proposes yes. Alternative: omit value entirely when both factors missing, forcing the agent to inspect close_raw directly. Latter is purer; former is friendlier to existing prompts. Decision: friendlier wins (spec §6.1).

  2. Is market_state exposed to agent useful? Agents that reason about staleness should know if the market is open/closing/closed. But it adds prompt complexity. Decision: include it; agents can ignore.

  3. What about /fundamentals route? This spec covers /price only. /fundamentals cutover is W5 scope (it uses core.data.schemas.get_fundamentals, not currently Composer-routed).

  4. Should source_chain always be a list (even length 1)? Yes; uniformity > saving 2 chars.

  5. Tool call ID — currently regenerated per route invocation. Is that a problem if agent caches by tool_call_id? No — Hermes doesn't deduplicate by tool_call_id today; it just uses it as a reference.

  6. Old envelope served_by field maps to new top-level source field. Both are filled with provider id (e.g., "tushare-daily"). Should we keep cite.served_by as a back-compat synonym? No — one source of truth. Update SKILL.md references/price-freshness.md.

  7. Hermes profile autoreload? If hermes restart is too heavy, does the profile reload SKILL.md / scripts automatically? TBD; verify with user. If yes, simplify §6.3.


12. Acceptance criteria for W4 PR(s)

When the cron writes W4 implementation:

  • [ ] src/service/routes/price.py uses app.state.composer; no direct DailyCache or schemas.get_price() reference
  • [ ] /price integration tests cover all 17 new fields, error paths, qfq math
  • [ ] skill/stock-research/scripts/fetch_price.py emits new shape + back-compat value/metric
  • [ ] skill/stock-research/SKILL.md + references/*.md updated
  • [ ] service.cache.DailyCache removed (or migrated to be a thin wrapper around QuoteCache for tests)
  • [ ] core.data._market removed
  • [ ] Manual operator runbook (§7) saved as docs/operations/runbook-w4-cutover.md
  • [ ] Rollback procedure (§8) saved next to it
  • [ ] All existing test_service_price_route tests still pass (after shape update)
  • [ ] fetch_price.py integration tests cover both new envelope and back-compat value path

13. Cross-references

  • Target schema definition: 02-tushare-integration-design.md §9.2
  • Lifespan prerequisite: 2026-05-10-lifespan-integration-spec.md §4
  • Plan slot: 2026-05-08-deployment-toolset-rollout.md §7 W4
  • Composer contract: 02-tushare-integration-design.md §6
  • AuditLog usage: 02-tushare-integration-design.md §8
  • Skill client: skill/stock-research/scripts/_client.py

In-flight PRs that must merge first:

PRTitleWhy W4 needs it
#38W3 T1 warehouse ingestT2.7 needs to land first; T2.7 needs scheduler in lifespan
#39QuoteCacheComposer reads it; new envelope includes cache-aware freshness
#41AuditLogComposer writes per-call audit; new envelope's source_chain reflects it
#42 / #43Composer ↔ AuditLog wiringAudit/freshness flow live during /price
#44trade_cal-aware TTLCache uses precise expiry; freshness_seconds accurate
#46AuditLog webhookOptional but lets W4 cutover surface unexpected diffs immediately
#47Lifespan integration specReference for T2.7 implementation
#48ServiceClient retryHermes profile's fetch_price.py gains retry — orthogonal but ships in same window

团队内部文档