跳轉到

量價事件框架 (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:

  1. Division by zeroBody/Spread, Position, VCR all guard against spread == 0 (limit-locked), max == min (flat window), and MA(V,20) == 0. The helpers return None rather than raising.
  2. Doji-fragile long-shadow rule — the framework's shadow > 2 × body is vacuous when body ≈ 0. Replaced with a spread-normalized rule: shadow/spread ≥ 0.5 and shadow ≥ 2 × body and the shadow dominates the opposite side (lower > upper), which rejects symmetric doji bars.
  3. 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 爆量.
  4. Non-portable thresholds — all thresholds are TW-tuned constants in volume_helpers.py (mirrored where the event layer needs them); per-market calibration via config/core.yaml is 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.py DailyTechnicalSnapshot — 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.pyta_volume_price_action row.
  • 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_domain stories 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.yaml per 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.