主题
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 §9 | Target response schema (the canonical "what") |
2026-05-10-lifespan-integration-spec.md | T2.7 lifespan wiring (the prerequisite) |
2026-05-08-deployment-toolset-rollout.md §7 W4 | Plan 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
- Single cutover moment. Server new schema + clients adapted + Hermes profile reinstalled, all within one hour.
- 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.
- Fast rollback. If post-cutover issues surface, server revert restores old envelope within minutes (Hermes profile gracefully handles both during the window).
- 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.
valuewas always ambiguous (qfq? raw? hfq?). Renaming toclose_rawis the point. - Migrating non-skill consumers (
fetch_price.pyis 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 field | New field | Note |
|---|---|---|
value | close_qfq (when adj factors available) or close_raw | The old "value" was unmarked w.r.t. adjustment; new schema disambiguates |
metric | dropped | Implied by which key holds the number; new shape always returns multiple numbers |
code | ts_code | Verbatim 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.source | source (top-level) | Promoted |
cite.table | dropped | Provider-internal detail; not load-bearing for consumers |
cite.served_by | source (top-level) | Same data; deduplicate |
cite.fetched_at | as_of (top-level) | Verbatim, promoted |
cite.tool_call_id | cite.tool_call_id | Verbatim |
cite.kind | cite.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_raw | OHLC unadjusted; close was implicit before |
| (new) | close_hfq | 后复权 close (Quote property) |
| (new) | volume / amount | Tushare ships these but old envelope dropped them |
| (new) | adj_factor / adj_factor_latest | Required for qfq/hfq math |
| (new) | freshness_seconds | How stale the data is right now |
| (new) | market_state | OPEN / CLOSING / CLOSED_FINAL / PRE_OPEN |
| (new) | units | Per-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 byQuoteCachecore/core/data/_market.py— superseded byMarketStateResolver- The
_envelope_from_cache()helper currently inroutes/price.py - The direct
core.data.schemas.get_pricecall 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.pystill 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:
- Read the new top-level fields (
close_qfqis the primary "current price", withclose_rawas fallback when factors are unavailable) - Decide which value the agent gets in
value(back-compat for prompt templates that still readvalue) - Surface
freshness_secondsandmarket_stateto the agent so it can reason about staleness - Preserve the
citeblock (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 0This 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 withvalue,as_of,metric, and aciteblock." → update to mention new shape + back-compatvalue/metricfields.references/tool-usage.md: per-tool envelope shape. Major rewrite.references/price-freshness.md:served_bymatrix → expanded withsource_chainandfreshness_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 + referencesThen 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 branch8. 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+ 4cite - Verifies
close_qfqmath when factors present - Verifies
close_qfq is Nonewhen factors absent - Verifies 502 on ProviderUnavailable
- Verifies 500 on CapabilityNotSupported / CanonicalUnitViolation
- Hits
core/tests/integration/test_skill_scripts.py:- Updated
test_service_mode_calls_backendto mock new shape; verifies fetch_price.py emitsvalue(back-compat) + new fields
- Updated
9.2 Post-cutover (live)
curl /price?code=600519.SHreturns new shapecurl /price?code=600519.SH&trade_date=2024-01-01returns historical with non-null qfqcurl /price?code=NONEXISTENT.SHreturns 502 (not 500)fetch_price.py 600519from Hermes profile emits agent-readable output containing newfreshness_secondsandmarket_state- Hermes agent quotes price in chat: works without prompt rewrite
9.3 Performance regression check
- Pre-cutover
/pricep95 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)
/priceroute's current cache (service.cache.DailyCache) — being retired in cutover; depends onQuoteCache(PR #39) being merged
W4 cutover uses the following components already merged or in-flight:
| Component | Status |
|---|---|
QuoteComposer | Merged (#34) |
TushareDailyProvider | Merged (#32) |
AkshareSpotProvider | Merged (#33) |
MarketStateResolver | Merged (#30) |
WarehousePool | Merged (#26) |
QuoteCache | In-flight (#39) |
AuditLog | In-flight (#41) |
| Composer ↔ AuditLog | In-flight (#42) |
| Freshness sync | In-flight (#43) |
| AuditLog webhook | In-flight (#46) |
| ServiceClient retry | In-flight (#48) |
| User PR #38 (W3 ingest) | In-flight |
11. Open questions
Should
close_qfq is Noneshort-circuit toclose_rawinvalueback-compat? Spec §6.1 above proposes yes. Alternative: omitvalueentirely when both factors missing, forcing the agent to inspectclose_rawdirectly. Latter is purer; former is friendlier to existing prompts. Decision: friendlier wins (spec §6.1).Is
market_stateexposed 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.What about
/fundamentalsroute? This spec covers/priceonly./fundamentalscutover is W5 scope (it usescore.data.schemas.get_fundamentals, not currently Composer-routed).Should
source_chainalways be a list (even length 1)? Yes; uniformity > saving 2 chars.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.
Old envelope
served_byfield maps to new top-levelsourcefield. Both are filled with provider id (e.g., "tushare-daily"). Should we keepcite.served_byas a back-compat synonym? No — one source of truth. Update SKILL.mdreferences/price-freshness.md.Hermes profile autoreload? If
hermes restartis 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.pyusesapp.state.composer; no directDailyCacheorschemas.get_price()reference - [ ]
/priceintegration tests cover all 17 new fields, error paths, qfq math - [ ]
skill/stock-research/scripts/fetch_price.pyemits new shape + back-compatvalue/metric - [ ]
skill/stock-research/SKILL.md+references/*.mdupdated - [ ]
service.cache.DailyCacheremoved (or migrated to be a thin wrapper around QuoteCache for tests) - [ ]
core.data._marketremoved - [ ] 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.pyintegration tests cover both new envelope and back-compatvaluepath
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:
| PR | Title | Why W4 needs it |
|---|---|---|
| #38 | W3 T1 warehouse ingest | T2.7 needs to land first; T2.7 needs scheduler in lifespan |
| #39 | QuoteCache | Composer reads it; new envelope includes cache-aware freshness |
| #41 | AuditLog | Composer writes per-call audit; new envelope's source_chain reflects it |
| #42 / #43 | Composer ↔ AuditLog wiring | Audit/freshness flow live during /price |
| #44 | trade_cal-aware TTL | Cache uses precise expiry; freshness_seconds accurate |
| #46 | AuditLog webhook | Optional but lets W4 cutover surface unexpected diffs immediately |
| #47 | Lifespan integration spec | Reference for T2.7 implementation |
| #48 | ServiceClient retry | Hermes profile's fetch_price.py gains retry — orthogonal but ships in same window |