量價事件框架 (Volume-Price Event Framework)
Implementation spec for the 6-event 量價 framework on top of Cortex's replacement signal → event → story pipeline.
Status:
- Phase 0 (done) — pure analytical primitives in
backend/data_pipeline/volume_helpers.py (+ tests/test_volume_price_indices.py).
- Phase 1 (done) — new ta_volume_price_action signal, snapshot columns, the
6 events, and stories (+ tests/test_volume_price_framework.py).
- Phase 2 (done) — cross-pipeline population of turnover (TR) from
balance_sheet and the disposition flag from market_surveillance_events
(_load_turnover_pct / _load_disposition_flag).
1. Core indices
| Index | Source | Notes |
|---|---|---|
VCR (量能壓縮比率) |
volume_ratio (existing column) |
current_volume / avg_volume_20. |
Position(P) |
donchian_20_position / donchian_55_position (existing) |
_channel_position() already computes (close − low_N)/(high_N − low_N). Reused — no new position column. |
| Candle geometry | new columns candle_body_ratio, candle_upper_shadow_ratio, candle_lower_shadow_ratio, candle_is_bullish |
Computed in the producer via volume_helpers, stored as spread-normalized ratios so the (pure) event layer needs no geometry recompute. |
down_close_streak |
new column | Consecutive lower-close days, for ⑥ 無量陰跌. |
TR (週轉率) |
new column turnover_pct |
volume / shares_outstanding × 100. Shares derived from balance_sheet.share_capital (TW-only). Optional booster, never a hard gate. |
is_disposition |
new column | From market_surveillance_events (TW twse/tpex). Distinguishes ① organic vs ② disposition-induced low volume. |
2. Fixes applied to the source framework
The framework as written has four classes of defect; the implementation corrects them:
- Division by zero —
Body/Spread,Position,VCRall guard againstspread == 0(limit-locked),max == min(flat window), andMA(V,20) == 0. The helpers returnNonerather than raising. - Doji-fragile long-shadow rule — the framework's
shadow > 2 × bodyis vacuous whenbody ≈ 0. Replaced with a spread-normalized rule:shadow/spread ≥ 0.5andshadow ≥ 2 × bodyand the shadow dominates the opposite side (lower > upper), which rejects symmetric doji bars. - Over-strict volume ladder (③) — strict
V_t > V_{t-1} > V_{t-2}under-fires on noisy real uptrends. Relaxed to a rising-volume regime (MA(V,5) > MA(V,20)) with an upper VCR cap so 階梯 stays distinct from 爆量. - Non-portable thresholds — all thresholds are TW-tuned constants in
volume_helpers.py(mirrored where the event layer needs them); per-market calibration viaconfig/core.yamlis a planned follow-up. TR/disposition are TW-only and degrade gracefully (None→ condition simply not asserted).
3. The 6 events (ta_volume_price_action)
Thresholds are the *_* constants in volume_helpers.py.
Thresholds below include the Gemini-review refinements (migration 079 added
ma20_slope and new_low_20d_in_last_5d). Conditions that depend on an index
are asserted only when that index is present, so non-TW / early-warmup bars
degrade gracefully.
| # | Event code | Dir | Gate (all of) |
|---|---|---|---|
| ① | vp_organic_suffocation_bottom |
觀察(support) | Position20 ≤ 0.25, VCR ≤ 0.25, flat range < 1.5%, not disposition, new_low_20d_in_last_5d = 0 (not making new lows — separates from ⑥). |
| ② | vp_disposition_suffocation |
中性(neutral) | is_disposition, VCR ≤ 0.5, close holds above MA20. |
| ③ | vp_staircase_breakout |
多(bullish) | C > MA5 > MA20, ma20_slope = positive, 1 ≤ VCR ≤ 2.5, solid red (body_ratio ≥ 0.6), rising-volume regime MA(V,5) > MA(V,20). |
| ④ | vp_high_volume_bottom_reversal |
多(bullish) | Position55 ≤ 0.25 (mid-horizon, anti bull-trap), VCR ≥ 2.0, range > 0 (no limit-lock), long lower shadow or strong solid red (≥ 0.7). |
| ⑤ | vp_high_volume_climax_top |
空(bearish) | Position20 ≥ 0.85 and Position55 ≥ 0.80, VCR ≥ 4.0, range ≥ 1.5% (anti-doji), long upper shadow or solid black. |
| ⑥ | vp_low_volume_drift_down |
空(bearish) | C < MA20, ma20_slope = negative, VCR ≤ 0.5, down_close_streak ≥ 3, new_low_20d_in_last_5d ≥ 2. |
Follow-up confirmation events:
| Base | Confirmation event code | Dir | T+1 confirmation |
|---|---|---|---|
| ① | vp_organic_suffocation_confirmed |
多(bullish) | Prior row flagged vp_organic_suffocation_bar; today closes above MA5 with a bullish/red candle. |
| ② | vp_disposition_release_confirmed |
多(bullish) | Prior row flagged vp_disposition_suffocation_bar; today is no longer under disposition and prints VCR ≥ 1.5 with a solid red body. |
| ④ | vp_bottom_reversal_confirmed |
多(bullish) | Prior row flagged vp_bottom_reversal_bar; today holds the reversal bar low. |
| ⑤ | vp_climax_top_confirmed |
空(bearish) | Prior row flagged vp_climax_top_bar; today closes below the prior body midpoint. |
Event ③ vp_staircase_breakout is intentionally same-day/direct-entry: its
gate already requires price stacked above MA5/MA20, positive MA20 slope,
volume expansion, and a solid red body. It does not create a separate
vp_staircase_breakout_confirmed event.
Each event adds a branch in replacement_event_layer._explicit_event_predicate
keyed on its event_code, reading the stored ratios/positions from the source
signal's index_evidence.
T+1 confirmation (migrations 080/081): the producer flags each setup bar and,
on the next session, reads the prior flag + bar levels to emit the confirmation
events above. This is done with indices + a one-row lookback, not a separate
state machine — _load_prev_vp_bar reads the previous snapshot row.
Still deferred: the ⑥ buy-lockout permission enforcement — the event already emits the bearish signal; blocking/forcing orders belongs to the downstream execution/risk layer, not the signal layer.
4. Stories
The framework events feed both single-event display stories (framework-named,
e.g. 高檔爆量出貨) and — the highest-value extension — cross-domain 量價×籌碼
stories via the existing cross_domain story layer (e.g. ④ ⊕ 法人買超,
⑤ ⊕ 主力賣超/融資爆增). Framework stories sit in conflict groups that supersede
the coarser proxy stories shipped earlier.
5. Registration checklist (per Phase 1)
database/migrations/078_volume_price_action.sql— new columns.database/models.pyDailyTechnicalSnapshot— same columns.scripts/daily_analysis_snapshot.py— inline DDL, column list, producer population (geometry via helpers,down_close_streak), upsert payload.config/replacement_signal_definitions.py—ta_volume_price_actionrow.services/replacement_signal_layer.py— state/score/conflict registration.config/replacement_event_definitions.py— 6 event rows + count constants.services/replacement_event_layer.py— 6 predicate branches.config/replacement_story_definitions.py— stories + conflict groups + count.tests/— in-memory signal→event→story coverage.docs/api-reference.md— if response vocabulary is enumerated.
6. Verification
The signal→event→story chain runs on a plain snapshot dict (no DB), so it is
unit-tested in-memory. The DB migration / ORM / schema-parity and the producer's
cross-pipeline queries are validated by CI (test.yml: schema-parity +
diff-cover), since they cannot run in a DB-less sandbox.
7. Follow-ups (tracked)
- 量價 × 籌碼 cross-domain stories ✅ done: three multi-event
cross_domainstories combining a volume-price event with a chip event —vp_chip_smart_money_entry(主力真金白銀進場: ④ low-volume bottom reversal ⊕ institutional net buying),vp_chip_distribution_trap(散戶接刀主力倒貨: ⑤ climax top ⊕ institutional distribution / margin build-up / large-holder distribution),vp_chip_capitulation_risk(流動性枯竭主力棄守: ⑥ low-volume drift ⊕ foreign selling / large-holder distribution). These cross-confirm price + flow, unlike the single-event 量價 stories. - T+1 for ①②③ ✅ done: ① 隔日突破 MA5 收紅, ② 出關表態
(
volume_ratio ≥ 1.5+ solid red after release), ③ direct entry documented as same-day/no confirmation. - ⑥ buy-lockout enforcement — execution/risk-layer permission gating on the 無量陰跌 signal (the signal is already emitted; blocking orders is downstream).
- Per-market threshold config — move TW-tuned constants into
config/core.yamlper market (TW/US/JP).
8. Historical backfill (issue #481)
Migrations 078/079/080/081 add the columns as nullable; existing rows stay NULL
until the producer is re-run, so 量價 events don't fire on historical dates until
a backfill runs. The snapshot upsert is ON CONFLICT DO UPDATE (idempotent).
Backfill the full window precisely with --start-date (fills the inclusive
[start_date, target_date] range; do not pass --skip-existing, since every
historical row already exists and must be updated):
python -m scripts.daily_analysis_snapshot --market taiwan \
--start-date 2018-01-02 --date <latest_trading_date>
(--backfill-days N remains available as the count-based mode; --start-date
overrides it.) Caveats: the earliest ~55 trading days have no donchian_55
(④⑤ silent), ~240 days lack ma240-class indicators; is_disposition /
turnover_pct are only correct within their source tables' historical coverage
(else False/None). The job is heavy (years × all TW tickers) — run as a batch,
resumable via re-run.