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:

UpgradeRationale
Add dividend history field to ticker data modelRequired 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 SQLiteAvoid 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)

FactorSourceImplementation
Dividend yield screenIDEA-002 SUPPORTS (dynamic)Walk-forward quarterly rebalance, ≥4.5% dividend yield threshold
Buy & Hold + annual rebalancingPhase 1 conclusionAnnual rebalance with >2% drift threshold for $2K portfolios

Refuted Factors (explicitly NOT included)

FactorSourceDecision
Momentum + volatility filterIDEA-003 REFUTEDNo vol gating — pure momentum entry only
Sector rotation via RSIDEA-004 REFUTEDHold 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 merged

Module 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)

ComponentRate
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 rangeRebalance frequencyRationale
5KAnnual + >2% drift thresholdQuarterly = 102% of starting capital/year in fees
20KSemi-annual or quarterly + drift filterFees manageable; semi-annual reduces fee drag ~30%
$50K+Full quarterly rebalancing viableFee 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 > 0

Module 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.txt for 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)

#DeliverableStatusDepends on
1signal-engine-v2-design.md — this document✅ DonePhase 1 conclusions
2Fee gate module (fee_gate.py) prototype⬜ NextThis design
3Dividend layer spec with DRIP compounding⬜ After #2Fee gate, dividend study
4Mock data pipeline generating ASX universe⬜ After #2Ticker list from May 24
5Signal store (SQLite schema) for paper trading⬜ ParallelAll 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 UItrading.paralla.org frontend. 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 data
  • results/dividend_reinvestment_study.json — DRIP +485pp edge findings