Signal Engine V2 Design
Overview
Signal engine that produces actionable buy/sell/hold signals from ASX market data, with fee-cost gating and dividend-compounding awareness as first-class concerns — not afterthoughts.
Core Principle (from Phase 1)
Flat brokerage (3.50 sell-deposit) is the dominant cost for <2K portfolio. Signal engine must calculate fee cost vs expected gain before emitting a trade.
Architecture
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Data Ingest │────▶│ Factor Engine │────▶│ Fee Gate (NEW) │────▶│ Signal Store │
│ (yfinance) │ │ (momentum, │ │ cost > gain? │ │ (SQLite) │
└─────────────┘ │ dividend) │ └─────────────────┘ └──────────────┘
└──────┬───────┘ │
│ │
┌──────▼───────┐ ┌───▼──────────┐
│ Dividend Lyr │◀─────────────────────────────│ Paper Trader │
│ (compounding) │ └──────────────┘
└──────────────┘ ↑
│
┌────────▼───────┐
│ IBKR Adapter │
│ (live/paper) │
└────────────────┘
Module 1: Data Ingest (asx_data_feed.py)
Already exists in scripts/paper_trading/asx_data_feed.py. V2 upgrades needed:
| Upgrade | Rationale |
|---|---|
| Add dividend history field to ticker data model | Required for DRIP layer |
| Tag data source (yfinance vs paid ASX API) | yfinance ~69% coverage on small-caps — signals must degrade gracefully |
| Cache raw OHLCV in local SQLite | Avoid re-fetching during backtest sweeps |
Ticker Universe Source of Truth
# Proposed data model
class TickerUniverse:
"""Canonical list of tradable ASX tickers with metadata."""
def __init__(self, source: Literal["yfinance", "asx-api"]):
self.tickers = [] # List[Ticker]
def add(self, ticker: str, sector: str, mcap_tier: str, data_quality: float) -> None: ...Blocker: yfinance alone insufficient for small-cap coverage (69% success rate per Phase 1). V2 must support pluggable universe sources. For now, maintain a static asx_universe.csv built from the May 24 extraction in results/asx_directory_raw.html.
Module 2: Factor Engine (factor_engine.py) — NEW
Implements the strategy signals identified as SUPPORTED in Phase 1 IDEA tests:
Supported Factors (confirmed by research)
| Factor | Source | Implementation |
|---|---|---|
| Dividend yield screen | IDEA-002 SUPPORTS (dynamic) | Walk-forward quarterly rebalance, ≥4.5% dividend yield threshold |
| Buy & Hold + annual rebalancing | Phase 1 conclusion | Annual rebalance with >2% drift threshold for $2K portfolios |
Refuted Factors (explicitly NOT included)
| Factor | Source | Decision |
|---|---|---|
| Momentum + volatility filter | IDEA-003 REFUTED | No vol gating — pure momentum entry only |
| Sector rotation via RS | IDEA-004 REFUTED | Hold all sectors equally; no sector rotation layer |
Factor Pipeline
class Signal:
"""One actionable signal from the engine."""
ticker: str
direction: Literal["buy", "sell", "hold"]
strength: float # 0.0 to 1.0
fee_cost: float # Total round-trip fee in AUD
expected_gain_pct: float # Expected % return before fees
net_edge: float # expected_gain - fee_cost (negative = skip)
def should_trade(self, capital: float, fee_schedule: FeeSchedule) -> bool:
"""Fee gate: only trade if net edge is positive after fees."""
return self.net_edge > 0 and self.fee_cost < capital * 0.01 # cap at 1% of portfolio
class FactorEngine:
def run(self, tickers: list[str], prices: dict, dividends: dict) -> list[Signal]:
"""Run all factors, merge into ranked signal list."""
buy_hold_signals = self._buy_and_hold(tickers, prices)
div_yield_signals = self._dividend_yield_screen(tickers, dividends)
merged = self._merge(buy_hold_signals, div_yield_signals)
return mergedModule 3: Fee Gate (fee_gate.py) — NEW, CRITICAL
This is the new first-class component that every signal must pass through. Based on fee-drag sensitivity analysis from May 24–31.
Fee Schedule (IBKR AU)
| Component | Rate |
|---|---|
| Buy brokerage | $9.90 per trade |
| Sell-deposit | $3.50 per sell |
| Round-trip cost | $13.40 per position change |
Fee-Aware Frequency Tiers (from trade-pattern-v1.md)
| Capital range | Rebalance frequency | Rationale |
|---|---|---|
| 5K | Annual + >2% drift threshold | Quarterly = 102% of starting capital/year in fees |
| 20K | Semi-annual or quarterly + drift filter | Fees manageable; semi-annual reduces fee drag ~30% |
| $50K+ | Full quarterly rebalancing viable | Fee impact <1% of capital annually |
Implementation
class FeeSchedule:
buy_cost = 9.90
sell_cost = 3.50
def round_trip(self) -> float:
return self.buy_cost + self.sell_cost # $13.40
def cost_for_universe(self, n_tickers: int) -> float:
"""Full rebalance cost for N tickers."""
sells = n_tickers * self.sell_cost
buys = n_tickers * self.buy_cost
return sells + buys
def fee_gate(signal: Signal, capital: float, schedule: FeeSchedule) -> bool:
"""Block signal if fee cost exceeds expected gain or 1% of portfolio."""
if signal.fee_cost > schedule.round_trip() * 1.5: # sanity cap
return False
if signal.fee_cost > capital * 0.01: # never spend >1% on fees
return False
return signal.net_edge > 0Module 4: Dividend Tracking Layer (dividend_layer.py) — NEW
Per Phase 1 architecture constraint #3: “Dividend reinvestment data needed — DRIP adds ~485pp over BH on ASX compounders. Control surface must track cumulative dividend impact separately from price movement.”
class DividendLayer:
"""Separate from price data; compounds dividends into position sizing."""
def __init__(self, tickers: list[str], rebalance_frequency: str = "annual"):
self.drivers: dict[str, float] = {} # ticker -> dividend yield
def compound(self, entry_price: float, days_held: int, dividends_paid: list[float]) -> float:
"""Calculate DRIP-adjusted position value."""
# Simple DRIP compounding model
total_dividends = sum(dividends_paid)
additional_shares = total_dividends / entry_price
return (1 + additional_shares) * 100 # return in % of initial
def expected_edge_over_bh(self, ticker: str, years: float) -> float:
"""Estimate DRIP edge over price-only BH for this ticker.
From Phase 1 research: ~485pp on ASX compounders (13-ticker, 2015-2026).
This is a back-of-envelope estimate using historical dividend growth rate.
"""
...Module 5: Mock Data Pipeline (mock_data.py) — NEW
For Phase 3 control surface development without IBKR credentials. Generates realistic synthetic ASX data based on the actual universe extracted in May 24.
Features
- Reads
results/asx_ticker_list_2026-05-24.txtfor ticker universe - Generates OHLCV with configurable volatility regime
- Injects dividend events based on Phase 1 findings (DRIP ~485pp edge)
- Outputs in same format as yfinance so signal engine is unchanged
def generate_mock_data(
tickers: list[str],
start_date: str,
end_date: str,
volatility_regime: Literal["low", "medium", "high"] = "medium",
dividend_frequency: str = "semi-annual",
) -> dict[str, pd.DataFrame]:
"""Generate mock ASX data for control surface development."""
...Phase 3 Deliverables (Control Surface Architecture)
| # | Deliverable | Status | Depends on |
|---|---|---|---|
| 1 | signal-engine-v2-design.md — this document | ✅ Done | Phase 1 conclusions |
| 2 | Fee gate module (fee_gate.py) prototype | ⬜ Next | This design |
| 3 | Dividend layer spec with DRIP compounding | ⬜ After #2 | Fee gate, dividend study |
| 4 | Mock data pipeline generating ASX universe | ⬜ After #2 | Ticker list from May 24 |
| 5 | Signal store (SQLite schema) for paper trading | ⬜ Parallel | All above |
What’s NOT in Phase 3 (Phase 2/4 deferral)
- Live IBKR connection — BLOCKED on
ibkr-creds. Paper trading adapter spec defined but not implemented. - Control surface UI —
trading.paralla.orgfrontend. That’s Phase 4; Phase 3 is the backend engine + mock data. - Small-cap screen signals — DATA QUALITY BLOCKED. yfinance only covers 69% of small-caps. Needs paid ASX API (Phase 2 research dependency).
References
- arch-phase-1-conclusions — Phase 1 architecture constraints and IDEA test results
- trade-pattern-v1 — Fee-aware rebalancing tiers, expected performance table
results/fee_drag_sensitivity.json— May 31 fee drag analysis dataresults/dividend_reinvestment_study.json— DRIP +485pp edge findings