import 'package:equatable/equatable.dart'; /// Client-only, derived indicator (not a server field). See [TradeModel.driftStatus]. /// /// `exitAlert` was removed on purpose: it used to be driven by a /// `hasPendingExitAlert`/`pendingExitReason` pair that no backend DTO ever /// produces anymore. Keeping the enum value around would let the UI keep a /// dead branch alive that can never be reached from real data (Rules.md §4). enum DriftStatus { onTrack, trailingActive, driftWarning, } /// Typed counterpart of the backend `SignalDirection` enum /// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`), serialized /// as a JSON string via `JsonStringEnumConverter` (unmodified member name, /// e.g. `"Buy"`). enum SignalDirection { buy, sell, neutral; static SignalDirection fromJson(dynamic value) { switch (value) { case 'Buy': return SignalDirection.buy; case 'Sell': return SignalDirection.sell; case 'Neutral': return SignalDirection.neutral; default: throw FormatException('Unknown SignalDirection from server: $value'); } } bool get isLong => this == SignalDirection.buy; String get label => switch (this) { SignalDirection.buy => 'LONG', SignalDirection.sell => 'SHORT', SignalDirection.neutral => 'NEUTRAL', }; } /// Typed counterpart of the backend `TradeStatus` enum /// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`). enum TradeStatus { proposed, active, breakEvenTriggered, tp1Hit, tp2Hit, closed, stoppedOut, invalidated, expired; static TradeStatus fromJson(dynamic value) { switch (value) { case 'Proposed': return TradeStatus.proposed; case 'Active': return TradeStatus.active; case 'BreakEvenTriggered': return TradeStatus.breakEvenTriggered; case 'Tp1Hit': return TradeStatus.tp1Hit; case 'Tp2Hit': return TradeStatus.tp2Hit; case 'Closed': return TradeStatus.closed; case 'StoppedOut': return TradeStatus.stoppedOut; case 'Invalidated': return TradeStatus.invalidated; case 'Expired': return TradeStatus.expired; default: throw FormatException('Unknown TradeStatus from server: $value'); } } String get label => switch (this) { TradeStatus.proposed => 'VORSCHLAG', TradeStatus.active => 'AKTIV', TradeStatus.breakEvenTriggered => 'BREAK-EVEN', TradeStatus.tp1Hit => 'TP1 ERREICHT', TradeStatus.tp2Hit => 'TP2 ERREICHT', TradeStatus.closed => 'GESCHLOSSEN', TradeStatus.stoppedOut => 'AUSGESTOPPT', TradeStatus.invalidated => 'INVALIDIERT', TradeStatus.expired => 'ABGELAUFEN', }; } /// Typed counterpart of the backend `ExecutionMode` enum /// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`). enum ExecutionMode { signalProposal, manualTradeRepublic, paperTradingBot; static ExecutionMode fromJson(dynamic value) { switch (value) { case 'SignalProposal': return ExecutionMode.signalProposal; case 'ManualTradeRepublic': return ExecutionMode.manualTradeRepublic; case 'PaperTradingBot': return ExecutionMode.paperTradingBot; default: throw FormatException('Unknown ExecutionMode from server: $value'); } } String get label => switch (this) { ExecutionMode.signalProposal => 'Signal (ohne Ausführung)', ExecutionMode.manualTradeRepublic => 'Manuell (Trade Republic)', ExecutionMode.paperTradingBot => 'Paper-Trading Bot', }; } /// Typed counterpart of the backend `InstrumentCategoryType` enum /// (see `FinlyticCore/Dtos/Trading/TradeEnums.cs`). enum InstrumentCategoryType { stock, etf, turboLong, turboShort, factorCertificate; static InstrumentCategoryType fromJson(dynamic value) { switch (value) { case 'Stock': return InstrumentCategoryType.stock; case 'Etf': return InstrumentCategoryType.etf; case 'TurboLong': return InstrumentCategoryType.turboLong; case 'TurboShort': return InstrumentCategoryType.turboShort; case 'FactorCertificate': return InstrumentCategoryType.factorCertificate; default: throw FormatException('Unknown InstrumentCategoryType from server: $value'); } } bool get isDerivative => this == turboLong || this == turboShort || this == factorCertificate; String get label => switch (this) { InstrumentCategoryType.stock => 'Aktie', InstrumentCategoryType.etf => 'ETF', InstrumentCategoryType.turboLong => 'Turbo Long', InstrumentCategoryType.turboShort => 'Turbo Short', InstrumentCategoryType.factorCertificate => 'Faktor-Zertifikat', }; } /// Typed counterpart of the backend `ExitStrategyType` enum /// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`). enum ExitStrategyType { stagedScaleOutWithBreakEven, pureTrailingStop, dynamicBandTouch, fixedSingleTarget, indicatorReversal; static ExitStrategyType fromJson(dynamic value) { switch (value) { case 'StagedScaleOutWithBreakEven': return ExitStrategyType.stagedScaleOutWithBreakEven; case 'PureTrailingStop': return ExitStrategyType.pureTrailingStop; case 'DynamicBandTouch': return ExitStrategyType.dynamicBandTouch; case 'FixedSingleTarget': return ExitStrategyType.fixedSingleTarget; case 'IndicatorReversal': return ExitStrategyType.indicatorReversal; default: throw FormatException('Unknown ExitStrategyType from server: $value'); } } String get label => switch (this) { ExitStrategyType.stagedScaleOutWithBreakEven => 'Stufenausstieg mit Break-Even', ExitStrategyType.pureTrailingStop => 'Reiner Trailing-Stop', ExitStrategyType.dynamicBandTouch => 'Dynamische Band-Berührung', ExitStrategyType.fixedSingleTarget => 'Fixes Einzelziel', ExitStrategyType.indicatorReversal => 'Indikator-Umkehr', }; } /// Typed counterpart of the backend `TrailingStopType` enum /// (see `FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs`). enum TrailingStopType { atrMultiplier, superTrendLine, swingPoints; static TrailingStopType fromJson(dynamic value) { switch (value) { case 'AtrMultiplier': return TrailingStopType.atrMultiplier; case 'SuperTrendLine': return TrailingStopType.superTrendLine; case 'SwingPoints': return TrailingStopType.swingPoints; default: throw FormatException('Unknown TrailingStopType from server: $value'); } } String get label => switch (this) { TrailingStopType.atrMultiplier => 'ATR-Multiplikator', TrailingStopType.superTrendLine => 'SuperTrend-Linie', TrailingStopType.swingPoints => 'Swing-Punkte', }; } double _reqNum(Map json, String key) { final val = json[key]; if (val is num) return val.toDouble(); throw FormatException('Expected numeric field "$key" but got: $val'); } int _reqInt(Map json, String key) { final val = json[key]; if (val is num) return val.toInt(); throw FormatException('Expected integer field "$key" but got: $val'); } String _reqStr(Map json, String key) { final val = json[key]; if (val is String) return val; throw FormatException('Expected string field "$key" but got: $val'); } /// Typed counterpart of the backend `TakeProfitStage` record /// (see `FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs`). class TakeProfitStageModel extends Equatable { final int stageNumber; final double targetPrice; final double percentToClose; final double rMultiple; final String description; const TakeProfitStageModel({ required this.stageNumber, required this.targetPrice, required this.percentToClose, required this.rMultiple, required this.description, }); factory TakeProfitStageModel.fromJson(Map json) { return TakeProfitStageModel( stageNumber: _reqInt(json, 'stageNumber'), targetPrice: _reqNum(json, 'targetPrice'), percentToClose: _reqNum(json, 'percentToClose'), rMultiple: _reqNum(json, 'rMultiple'), description: _reqStr(json, 'description'), ); } @override List get props => [stageNumber, targetPrice, percentToClose, rMultiple, description]; } /// Typed counterpart of the backend `BreakEvenRule` record. class BreakEvenRuleModel extends Equatable { final bool enabled; final double triggerPrice; final double offsetToCoverFees; const BreakEvenRuleModel({ required this.enabled, required this.triggerPrice, required this.offsetToCoverFees, }); factory BreakEvenRuleModel.fromJson(Map json) { return BreakEvenRuleModel( enabled: json['enabled'] == true, triggerPrice: _reqNum(json, 'triggerPrice'), offsetToCoverFees: _reqNum(json, 'offsetToCoverFees'), ); } @override List get props => [enabled, triggerPrice, offsetToCoverFees]; } /// Typed counterpart of the backend `TrailingStopRule` record. class TrailingStopRuleModel extends Equatable { final TrailingStopType type; final double multiplier; final double activationPrice; final String indicatorKey; const TrailingStopRuleModel({ required this.type, required this.multiplier, required this.activationPrice, required this.indicatorKey, }); factory TrailingStopRuleModel.fromJson(Map json) { return TrailingStopRuleModel( type: TrailingStopType.fromJson(json['type']), multiplier: _reqNum(json, 'multiplier'), activationPrice: _reqNum(json, 'activationPrice'), indicatorKey: _reqStr(json, 'indicatorKey'), ); } @override List get props => [type, multiplier, activationPrice, indicatorKey]; } /// Typed counterpart of the backend `ReversalCondition` record. class ReversalConditionModel extends Equatable { final String ruleDescription; final String indicatorTrigger; const ReversalConditionModel({ required this.ruleDescription, required this.indicatorTrigger, }); factory ReversalConditionModel.fromJson(Map json) { return ReversalConditionModel( ruleDescription: _reqStr(json, 'ruleDescription'), indicatorTrigger: _reqStr(json, 'indicatorTrigger'), ); } @override List get props => [ruleDescription, indicatorTrigger]; } /// Typed counterpart of the backend `ExitPlan` record /// (see `FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs`). /// /// `takeProfitStages` may legitimately be empty (e.g. [ExitStrategyType.pureTrailingStop] /// or [ExitStrategyType.indicatorReversal] manage the exit without discrete price /// targets) — callers MUST treat an empty list as "no fixed TP target", not as /// missing data to hide behind a fabricated €0.00 (Rules.md §4). class ExitPlanModel extends Equatable { final ExitStrategyType strategyType; final double initialStopLoss; final List takeProfitStages; final BreakEvenRuleModel? breakEvenRule; final TrailingStopRuleModel? trailingStopRule; final ReversalConditionModel? reversalCondition; final int? maxHoldingBars; const ExitPlanModel({ required this.strategyType, required this.initialStopLoss, required this.takeProfitStages, this.breakEvenRule, this.trailingStopRule, this.reversalCondition, this.maxHoldingBars, }); factory ExitPlanModel.fromJson(Map json) { final rawStages = json['takeProfitStages']; final beRaw = json['breakEvenRule']; final trailRaw = json['trailingStopRule']; final revRaw = json['reversalCondition']; return ExitPlanModel( strategyType: ExitStrategyType.fromJson(json['strategyType']), initialStopLoss: _reqNum(json, 'initialStopLoss'), takeProfitStages: rawStages is List ? rawStages.map((s) => TakeProfitStageModel.fromJson(Map.from(s))).toList() : const [], breakEvenRule: beRaw is Map ? BreakEvenRuleModel.fromJson(Map.from(beRaw)) : null, trailingStopRule: trailRaw is Map ? TrailingStopRuleModel.fromJson(Map.from(trailRaw)) : null, reversalCondition: revRaw is Map ? ReversalConditionModel.fromJson(Map.from(revRaw)) : null, maxHoldingBars: json['maxHoldingBars'] is num ? (json['maxHoldingBars'] as num).toInt() : null, ); } @override List get props => [strategyType, initialStopLoss, takeProfitStages, breakEvenRule, trailingStopRule, reversalCondition, maxHoldingBars]; } /// Typed counterpart of the backend `TradeFillDto` /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`) — one entry of the /// real execution history of a trade (initial entry fill plus any partial /// scale-outs). class TradeFillModel extends Equatable { final String fillId; final DateTime executedAtUtc; final double price; final double quantity; final double fee; final String? note; const TradeFillModel({ required this.fillId, required this.executedAtUtc, required this.price, required this.quantity, required this.fee, this.note, }); factory TradeFillModel.fromJson(Map json) { return TradeFillModel( fillId: _reqStr(json, 'fillId'), executedAtUtc: DateTime.parse(_reqStr(json, 'executedAtUtc')), price: _reqNum(json, 'price'), quantity: _reqNum(json, 'quantity'), fee: _reqNum(json, 'fee'), note: json['note'] as String?, ); } @override List get props => [fillId, executedAtUtc, price, quantity, fee, note]; } /// Typed counterpart of the backend `ActiveTradeDto` /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), delivered by /// `GET /api/v1/user/trades`. /// /// Every field below has a direct 1:1 match on `ActiveTradeDto` — no field is /// carried over from the old (now-removed) FinlyticAnalyzer-era shape. Fields /// that no longer exist server-side (analysisId, isGlobalProposal, userId, /// companyName, sector, entryZoneMin/Max, leverageUsed, maxLeverage, hasCfd, /// riskTolerance, timeframe, vixValue, vixRegime, winRate, reasoning, /// technicalRationale, fundamentalRationale, riskWarning, hourlyUpdates, /// closeReason, userExitTimestamp, hasPendingExitAlert, pendingExitReason) /// were removed rather than kept alive with a default value, because a /// default here would look like a real (but always-empty/zero) measurement /// to the UI (Rules.md §4). class TradeModel extends Equatable { final String id; final String proposalId; final String underlyingIsin; final String symbol; /// Nullable: only set once the engine has bound a concrete Knock-Out/Turbo /// product to this trade (see [InstrumentCategoryType.isDerivative]). final String? derivativeIsin; /// Nullable: Trade Republic (the only derivative data source in the /// system) never provides a WKN for its Knock-Out products, only an ISIN /// — this is always `null` today. It must never be back-filled with the /// ISIN or an empty string (Rules.md §4). final String? derivativeWkn; final ExecutionMode executionMode; final InstrumentCategoryType instrumentType; final SignalDirection direction; final TradeStatus status; final double averageBuyIn; final double totalQuantity; final double initialStopLoss; final double currentStopLoss; final double currentPrice; final double unrealizedPnlEur; final double unrealizedPnlPercent; final double realizedPnlEur; final ExitPlanModel exitPlan; final List fills; final DateTime openedAtUtc; final DateTime? closedAtUtc; const TradeModel({ required this.id, required this.proposalId, required this.underlyingIsin, required this.symbol, this.derivativeIsin, this.derivativeWkn, required this.executionMode, required this.instrumentType, required this.direction, required this.status, required this.averageBuyIn, required this.totalQuantity, required this.initialStopLoss, required this.currentStopLoss, required this.currentPrice, required this.unrealizedPnlEur, required this.unrealizedPnlPercent, required this.realizedPnlEur, required this.exitPlan, required this.fills, required this.openedAtUtc, this.closedAtUtc, }); /// A row that was accepted but has not received its first fill yet. Not /// currently produced by the engine's acceptance flow (every accepted /// proposal starts as [TradeStatus.active] with a real fill), but the /// status value exists server-side, so the UI must be able to render it. bool get isProposed => status == TradeStatus.proposed; /// A real, currently open position (including break-even/partial-TP /// states) — as opposed to a status that never resulted in an ongoing /// position ([isRejected]) or one that has been fully resolved ([isClosed]). bool get isActive => status == TradeStatus.active || status == TradeStatus.breakEvenTriggered || status == TradeStatus.tp1Hit || status == TradeStatus.tp2Hit; /// Fully resolved with a real (win or loss) outcome on deployed capital. bool get isClosed => status == TradeStatus.closed || status == TradeStatus.stoppedOut; /// Never resulted in a filled/ongoing position — the closest server-side /// equivalent of the old (removed) "Rejected" status. bool get isRejected => status == TradeStatus.invalidated || status == TradeStatus.expired; bool get isDerivative => instrumentType.isDerivative || (derivativeIsin != null && derivativeIsin!.isNotEmpty); /// Derived, client-only display hint — NOT a server field. Unlike the /// removed `hasPendingExitAlert`/`hourlyUpdates`-driven version, every /// branch here is backed by real server data: a configured trailing-stop /// rule, or the server-computed [unrealizedPnlPercent]. DriftStatus get driftStatus { if (exitPlan.trailingStopRule != null && isActive) return DriftStatus.trailingActive; if (isActive && unrealizedPnlPercent < -3.5) return DriftStatus.driftWarning; return DriftStatus.onTrack; } /// The price a closed trade was actually exited at. The engine sets /// `CurrentPrice` to the close price at closing time, so this is real data, /// not a re-derivation — only meaningful once [isClosed]. double get actualExitPrice => currentPrice; /// The euro P&L to display "right now": realized once resolved, otherwise /// the server's live unrealized figure. Never recomputed client-side. double get pnlEur => isClosed ? realizedPnlEur : unrealizedPnlEur; /// First take-profit target price, or `null` if the exit plan manages the /// exit without a discrete price target (e.g. pure trailing stop / /// indicator reversal). Callers MUST show an explicit "no fixed target" /// state instead of a fabricated €0.00 when this is `null` (Rules.md §4). double? get primaryTakeProfit => exitPlan.takeProfitStages.isEmpty ? null : exitPlan.takeProfitStages.first.targetPrice; factory TradeModel.fromJson(Map json) { final derivIsin = json['derivativeIsin'] as String?; final derivWkn = json['derivativeWkn'] as String?; final rawFills = json['fills']; return TradeModel( id: _reqStr(json, 'tradeId'), proposalId: _reqStr(json, 'proposalId'), underlyingIsin: _reqStr(json, 'underlyingIsin'), symbol: _reqStr(json, 'symbol'), derivativeIsin: (derivIsin != null && derivIsin.isNotEmpty) ? derivIsin : null, derivativeWkn: (derivWkn != null && derivWkn.isNotEmpty) ? derivWkn : null, executionMode: ExecutionMode.fromJson(json['executionMode']), instrumentType: InstrumentCategoryType.fromJson(json['instrumentType']), direction: SignalDirection.fromJson(json['direction']), status: TradeStatus.fromJson(json['status']), averageBuyIn: _reqNum(json, 'averageBuyIn'), totalQuantity: _reqNum(json, 'totalQuantity'), initialStopLoss: _reqNum(json, 'initialStopLoss'), currentStopLoss: _reqNum(json, 'currentStopLoss'), currentPrice: _reqNum(json, 'currentPrice'), unrealizedPnlEur: _reqNum(json, 'unrealizedPnlEur'), unrealizedPnlPercent: _reqNum(json, 'unrealizedPnlPercent'), realizedPnlEur: _reqNum(json, 'realizedPnlEur'), exitPlan: ExitPlanModel.fromJson(Map.from(json['exitPlan'] as Map)), fills: rawFills is List ? rawFills.map((f) => TradeFillModel.fromJson(Map.from(f))).toList() : const [], openedAtUtc: DateTime.parse(_reqStr(json, 'openedAtUtc')), closedAtUtc: json['closedAtUtc'] != null ? DateTime.parse(json['closedAtUtc'] as String) : null, ); } @override List get props => [ id, proposalId, underlyingIsin, symbol, derivativeIsin, derivativeWkn, executionMode, instrumentType, direction, status, averageBuyIn, totalQuantity, initialStopLoss, currentStopLoss, currentPrice, unrealizedPnlEur, unrealizedPnlPercent, realizedPnlEur, exitPlan, fills, openedAtUtc, closedAtUtc, ]; } /// Typed counterpart of the backend `DerivativeSelectionDto` /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`). class DerivativeSelectionModel extends Equatable { final String derivativeIsin; /// Nullable: Trade Republic (the only derivative data source in the system) /// does not provide a WKN for its Knock-Out products, only an ISIN. The /// backend used to (incorrectly) put the ISIN in this field as a stand-in; /// it now sends `null` instead. The UI MUST NOT render an empty string as /// if it were a real identifier (Rules.md §4) — treat `null` as "keine /// WKN verfügbar" and hide/label the field accordingly. final String? derivativeWkn; final String issuer; final String optionType; final double strike; final double barrier; final double leverage; final double safetyBufferPercent; final double spreadPercentage; final double size; const DerivativeSelectionModel({ this.derivativeIsin = '', this.derivativeWkn, this.issuer = '', this.optionType = '', this.strike = 0.0, this.barrier = 0.0, this.leverage = 0.0, this.safetyBufferPercent = 0.0, this.spreadPercentage = 0.0, this.size = 0.0, }); factory DerivativeSelectionModel.fromJson(Map json) { double parseDbl(dynamic val) { if (val == null) return 0.0; if (val is num) return val.toDouble(); return double.tryParse(val.toString()) ?? 0.0; } final rawWkn = json['derivativeWkn']?.toString(); return DerivativeSelectionModel( derivativeIsin: json['derivativeIsin']?.toString() ?? '', derivativeWkn: (rawWkn != null && rawWkn.isNotEmpty) ? rawWkn : null, issuer: json['issuer']?.toString() ?? '', optionType: json['optionType']?.toString() ?? '', strike: parseDbl(json['strike']), barrier: parseDbl(json['barrier']), leverage: parseDbl(json['leverage']), safetyBufferPercent: parseDbl(json['safetyBufferPercent']), spreadPercentage: parseDbl(json['spreadPercentage']), size: parseDbl(json['size']), ); } @override List get props => [ derivativeIsin, derivativeWkn, issuer, optionType, strike, barrier, leverage, safetyBufferPercent, spreadPercentage, size, ]; } /// Typed counterpart of the backend `ValidationSource` enum /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), serialized as a /// JSON string (`"Ai"` / `"RuleBased"`) via `JsonStringEnumConverter`. enum ValidationSource { ai, ruleBased, ; /// Backend default is `Ai` (enum value 0) — a webhook payload that omits /// this (new) field entirely must still be interpreted as a real AI result, /// matching the server-side default. static ValidationSource fromJson(dynamic value) { switch (value?.toString()) { case 'RuleBased': return ValidationSource.ruleBased; case 'Ai': return ValidationSource.ai; default: return ValidationSource.ai; } } } /// Typed counterpart of the backend `AiValidationResultDto` /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`). /// /// `confidence` is nullable and MUST stay that way: it is only populated /// when [source] is [ValidationSource.ai]. When the engine falls back to a /// rule-based approval (e.g. the n8n webhook is unreachable), the server /// sends `confidence: null` — inventing a number here (or defaulting to 0.0) /// would be exactly the fabricated-metric violation this field was /// introduced to eliminate (Rules.md §4). class AiValidationResultModel extends Equatable { final bool isApproved; final double? confidence; final ValidationSource source; final String thesisSummary; final String invalidationReason; final List keyCatalysts; final List identifiedRisks; const AiValidationResultModel({ this.isApproved = false, this.confidence, this.source = ValidationSource.ai, this.thesisSummary = '', this.invalidationReason = '', this.keyCatalysts = const [], this.identifiedRisks = const [], }); bool get isAiValidated => source == ValidationSource.ai; bool get hasContent => thesisSummary.trim().isNotEmpty || keyCatalysts.isNotEmpty || identifiedRisks.isNotEmpty; factory AiValidationResultModel.fromJson(Map json) { return AiValidationResultModel( isApproved: json['isApproved'] == true, confidence: (json['confidence'] as num?)?.toDouble(), source: ValidationSource.fromJson(json['validationSource']), thesisSummary: json['thesisSummary']?.toString() ?? '', invalidationReason: json['invalidationReason']?.toString() ?? '', keyCatalysts: (json['keyCatalysts'] as List?)?.map((e) => e.toString()).toList() ?? const [], identifiedRisks: (json['identifiedRisks'] as List?)?.map((e) => e.toString()).toList() ?? const [], ); } @override List get props => [isApproved, confidence, source, thesisSummary, invalidationReason, keyCatalysts, identifiedRisks]; } /// Typed counterpart of the backend `TradeProposalDto` /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), delivered live over /// `SignalRService.tradeProposalStream` (`/hubs/trade-stream`, event `ReceiveTradeProposal`). /// /// `selectedDerivative` and `aiValidation` are intentionally nullable: the /// server may legitimately omit them (no suitable Knock-Out product found / /// AI validation not yet run), and the UI MUST show an explicit empty state /// in that case instead of inventing plausible-looking numbers (Rules.md §4). class TradeProposalModel extends Equatable { final String proposalId; final String underlyingIsin; final String symbol; final String strategyKey; final String direction; final double qualityScore; final double compositeScore; final double currentPrice; final double entryPrice; final double invalidationPrice; final DerivativeSelectionModel? selectedDerivative; final AiValidationResultModel? aiValidation; final DateTime? createdAtUtc; final DateTime? expiresAtUtc; const TradeProposalModel({ required this.proposalId, required this.underlyingIsin, required this.symbol, required this.strategyKey, required this.direction, this.qualityScore = 0.0, this.compositeScore = 0.0, this.currentPrice = 0.0, this.entryPrice = 0.0, this.invalidationPrice = 0.0, this.selectedDerivative, this.aiValidation, this.createdAtUtc, this.expiresAtUtc, }); bool get isLong => direction.toUpperCase() == 'BUY' || direction.toUpperCase() == 'LONG'; factory TradeProposalModel.fromJson(Map json) { double parseDbl(dynamic val) { if (val == null) return 0.0; if (val is num) return val.toDouble(); return double.tryParse(val.toString()) ?? 0.0; } final derivRaw = json['selectedDerivative']; final aiRaw = json['aiValidation']; return TradeProposalModel( proposalId: (json['proposalId'] ?? json['id'])?.toString() ?? '', underlyingIsin: json['underlyingIsin']?.toString() ?? '', symbol: json['symbol']?.toString() ?? '', strategyKey: json['strategyKey']?.toString() ?? '', direction: json['direction']?.toString() ?? 'Buy', qualityScore: parseDbl(json['qualityScore']), compositeScore: parseDbl(json['compositeScore']), currentPrice: parseDbl(json['currentPrice']), entryPrice: parseDbl(json['entryPrice']), invalidationPrice: parseDbl(json['invalidationPrice']), selectedDerivative: derivRaw is Map ? DerivativeSelectionModel.fromJson(Map.from(derivRaw)) : null, aiValidation: aiRaw is Map ? AiValidationResultModel.fromJson(Map.from(aiRaw)) : null, createdAtUtc: DateTime.tryParse(json['createdAtUtc']?.toString() ?? ''), expiresAtUtc: DateTime.tryParse(json['expiresAtUtc']?.toString() ?? ''), ); } @override List get props => [ proposalId, underlyingIsin, symbol, strategyKey, direction, qualityScore, compositeScore, currentPrice, entryPrice, invalidationPrice, selectedDerivative, aiValidation, createdAtUtc, expiresAtUtc, ]; } /// Typed counterpart of the backend `AssetEvaluationResultDto` /// (see `FinlyticCore/Dtos/Trading/EngineTradeDtos.cs`), returned by /// `POST /api/v1/analyze/manual` and `POST /api/v1/engine/evaluate`. /// /// Unlike the old bare `TradeProposalModel?`/`204` contract, this is always /// fully populated — even when [proposal] is `null` (the pipeline ran the /// full evaluation but rejected the opportunity, or could not even find a /// technical setup for the asset) the real, already-computed scores and AI /// reasoning are still present, so the caller never has to show silence for /// "no proposal" (Rules.md §4). [daysToNextEarnings] stays nullable because /// "unknown/not applicable" and "0 days" are different facts and must not be /// collapsed into the same number. class AssetEvaluationResultModel extends Equatable { final TradeProposalModel? proposal; final double compositeScore; final double technicalScore; final double sentimentScore; final double fundamentalScore; final bool passedEarningsLockout; final int? daysToNextEarnings; final bool passedDividendGate; final int? daysToNextExDividend; final bool aiApproved; final String aiThesisSummary; final List aiIdentifiedRisks; const AssetEvaluationResultModel({ this.proposal, this.compositeScore = 0.0, this.technicalScore = 0.0, this.sentimentScore = 0.0, this.fundamentalScore = 0.0, this.passedEarningsLockout = true, this.daysToNextEarnings, this.passedDividendGate = true, this.daysToNextExDividend, this.aiApproved = false, this.aiThesisSummary = '', this.aiIdentifiedRisks = const [], }); /// True exactly when the pipeline produced an active trade proposal. bool get hasProposal => proposal != null; factory AssetEvaluationResultModel.fromJson(Map json) { double parseDbl(dynamic val) { if (val == null) return 0.0; if (val is num) return val.toDouble(); return double.tryParse(val.toString()) ?? 0.0; } final proposalRaw = json['proposal']; return AssetEvaluationResultModel( proposal: proposalRaw is Map ? TradeProposalModel.fromJson(Map.from(proposalRaw)) : null, compositeScore: parseDbl(json['compositeScore']), technicalScore: parseDbl(json['technicalScore']), sentimentScore: parseDbl(json['sentimentScore']), fundamentalScore: parseDbl(json['fundamentalScore']), passedEarningsLockout: json['passedEarningsLockout'] == true, daysToNextEarnings: json['daysToNextEarnings'] is num ? (json['daysToNextEarnings'] as num).toInt() : null, passedDividendGate: json['passedDividendGate'] == true, daysToNextExDividend: json['daysToNextExDividend'] is num ? (json['daysToNextExDividend'] as num).toInt() : null, aiApproved: json['aiApproved'] == true, aiThesisSummary: json['aiThesisSummary']?.toString() ?? '', aiIdentifiedRisks: (json['aiIdentifiedRisks'] as List?)?.map((e) => e.toString()).toList() ?? const [], ); } @override List get props => [ proposal, compositeScore, technicalScore, sentimentScore, fundamentalScore, passedEarningsLockout, daysToNextEarnings, passedDividendGate, daysToNextExDividend, aiApproved, aiThesisSummary, aiIdentifiedRisks, ]; }